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]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = [
|
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-common",
|
||||||
"linux-hello-daemon",
|
"linux-hello-daemon",
|
||||||
"linux-hello-cli",
|
"linux-hello-cli",
|
||||||
@@ -28,9 +36,9 @@ tokio = { version = "1.35", features = ["full"] }
|
|||||||
# Camera
|
# Camera
|
||||||
v4l = "0.14"
|
v4l = "0.14"
|
||||||
|
|
||||||
# ML/ONNX
|
# ML/ONNX (rc.11 is production-ready per ort docs)
|
||||||
ort = "2.0.0-rc.10"
|
ort = { version = "=2.0.0-rc.11", features = ["ndarray"] }
|
||||||
ndarray = "0.15"
|
ndarray = "0.16"
|
||||||
|
|
||||||
# Image processing
|
# Image processing
|
||||||
image = "0.24"
|
image = "0.24"
|
||||||
@@ -43,3 +51,6 @@ zeroize = { version = "1.8", features = ["derive"] }
|
|||||||
|
|
||||||
# TPM2 (for secure template storage)
|
# TPM2 (for secure template storage)
|
||||||
tss-esapi = "7.5"
|
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 serde::{Deserialize, Serialize};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use crate::error::{Error, Result};
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -22,62 +99,140 @@ pub struct Config {
|
|||||||
pub tpm: TpmConfig,
|
pub tpm: TpmConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// General system configuration.
|
||||||
|
///
|
||||||
|
/// Controls logging verbosity and authentication timeout.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct GeneralConfig {
|
pub struct GeneralConfig {
|
||||||
|
/// Log level: "error", "warn", "info", "debug", or "trace".
|
||||||
|
/// Default: "info"
|
||||||
#[serde(default = "default_log_level")]
|
#[serde(default = "default_log_level")]
|
||||||
pub log_level: String,
|
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")]
|
#[serde(default = "default_timeout")]
|
||||||
pub timeout_seconds: u32,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct CameraConfig {
|
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")]
|
#[serde(default = "default_auto")]
|
||||||
pub device: String,
|
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")]
|
#[serde(default = "default_auto")]
|
||||||
pub ir_emitter: String,
|
pub ir_emitter: String,
|
||||||
|
/// Capture resolution as [width, height] in pixels.
|
||||||
|
/// Default: [640, 480]
|
||||||
#[serde(default = "default_resolution")]
|
#[serde(default = "default_resolution")]
|
||||||
pub resolution: [u32; 2],
|
pub resolution: [u32; 2],
|
||||||
|
/// Target frame rate in frames per second.
|
||||||
|
/// Default: 30
|
||||||
#[serde(default = "default_fps")]
|
#[serde(default = "default_fps")]
|
||||||
pub fps: u32,
|
pub fps: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Face detection configuration.
|
||||||
|
///
|
||||||
|
/// Controls the face detection model and sensitivity thresholds.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct DetectionConfig {
|
pub struct DetectionConfig {
|
||||||
|
/// Face detection model to use: "blazeface" or "mtcnn".
|
||||||
|
/// Default: "blazeface"
|
||||||
#[serde(default = "default_model")]
|
#[serde(default = "default_model")]
|
||||||
pub model: String,
|
pub model: String,
|
||||||
|
/// Minimum face size in pixels for detection.
|
||||||
|
/// Faces smaller than this are ignored.
|
||||||
|
/// Default: 80
|
||||||
#[serde(default = "default_min_face_size")]
|
#[serde(default = "default_min_face_size")]
|
||||||
pub min_face_size: u32,
|
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")]
|
#[serde(default = "default_confidence_threshold")]
|
||||||
pub confidence_threshold: f32,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct EmbeddingConfig {
|
pub struct EmbeddingConfig {
|
||||||
|
/// Embedding extraction model: "mobilefacenet" or "arcface".
|
||||||
|
/// Default: "mobilefacenet"
|
||||||
#[serde(default = "default_embedding_model")]
|
#[serde(default = "default_embedding_model")]
|
||||||
pub model: String,
|
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")]
|
#[serde(default = "default_distance_threshold")]
|
||||||
pub distance_threshold: f32,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct AntiSpoofingConfig {
|
pub struct AntiSpoofingConfig {
|
||||||
|
/// Master switch to enable/disable all anti-spoofing checks.
|
||||||
|
/// Default: true
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
|
/// Enable depth estimation to detect flat images (photos/screens).
|
||||||
|
/// Default: true
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub depth_check: bool,
|
pub depth_check: bool,
|
||||||
|
/// Enable ML-based liveness detection model.
|
||||||
|
/// Default: true
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub liveness_model: bool,
|
pub liveness_model: bool,
|
||||||
|
/// Enable temporal analysis (micro-movements, blink detection).
|
||||||
|
/// Requires multiple frames and increases authentication time.
|
||||||
|
/// Default: true
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub temporal_check: bool,
|
pub temporal_check: bool,
|
||||||
|
/// Minimum combined liveness score (0.0-1.0) to pass anti-spoofing.
|
||||||
|
/// Default: 0.7
|
||||||
#[serde(default = "default_min_score")]
|
#[serde(default = "default_min_score")]
|
||||||
pub min_score: f32,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct TpmConfig {
|
pub struct TpmConfig {
|
||||||
|
/// Enable TPM-based encryption for face templates.
|
||||||
|
/// Default: true
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub enabled: bool,
|
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)]
|
#[serde(default)]
|
||||||
pub pcr_binding: bool,
|
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;
|
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)]
|
#[derive(Error, Debug)]
|
||||||
pub enum Error {
|
pub enum Error {
|
||||||
|
/// Camera hardware access error.
|
||||||
|
/// Includes the underlying system error message.
|
||||||
#[error("Camera error: {0}")]
|
#[error("Camera error: {0}")]
|
||||||
Camera(String),
|
Camera(String),
|
||||||
|
|
||||||
|
/// No suitable IR camera was detected on the system.
|
||||||
|
/// Ensure an IR camera is connected and accessible.
|
||||||
#[error("No IR camera found")]
|
#[error("No IR camera found")]
|
||||||
NoCameraFound,
|
NoCameraFound,
|
||||||
|
|
||||||
|
/// Failed to control the IR emitter (LED).
|
||||||
|
/// The emitter may be in use by another process.
|
||||||
#[error("IR emitter control failed: {0}")]
|
#[error("IR emitter control failed: {0}")]
|
||||||
IrEmitter(String),
|
IrEmitter(String),
|
||||||
|
|
||||||
|
/// Face detection failed during processing.
|
||||||
|
/// May indicate a model loading issue or corrupted frame.
|
||||||
#[error("Face detection error: {0}")]
|
#[error("Face detection error: {0}")]
|
||||||
Detection(String),
|
Detection(String),
|
||||||
|
|
||||||
|
/// No face was detected in the captured frame.
|
||||||
|
/// Ensure face is visible and properly lit.
|
||||||
#[error("No face detected in frame")]
|
#[error("No face detected in frame")]
|
||||||
NoFaceDetected,
|
NoFaceDetected,
|
||||||
|
|
||||||
|
/// Multiple faces were detected when only one is expected.
|
||||||
|
/// For security, authentication requires exactly one face.
|
||||||
#[error("Multiple faces detected")]
|
#[error("Multiple faces detected")]
|
||||||
MultipleFacesDetected,
|
MultipleFacesDetected,
|
||||||
|
|
||||||
|
/// Failed to load an ML model (detection or embedding).
|
||||||
#[error("Model loading error: {0}")]
|
#[error("Model loading error: {0}")]
|
||||||
ModelLoad(String),
|
ModelLoad(String),
|
||||||
|
|
||||||
|
/// Configuration file parsing or validation error.
|
||||||
#[error("Configuration error: {0}")]
|
#[error("Configuration error: {0}")]
|
||||||
Config(String),
|
Config(String),
|
||||||
|
|
||||||
|
/// TPM (Trusted Platform Module) operation failed.
|
||||||
|
/// May indicate TPM is unavailable or key access was denied.
|
||||||
#[error("TPM error: {0}")]
|
#[error("TPM error: {0}")]
|
||||||
Tpm(String),
|
Tpm(String),
|
||||||
|
|
||||||
|
/// Face did not match any enrolled template.
|
||||||
|
/// Generic error to prevent information disclosure.
|
||||||
#[error("Authentication failed")]
|
#[error("Authentication failed")]
|
||||||
AuthenticationFailed,
|
AuthenticationFailed,
|
||||||
|
|
||||||
|
/// The specified user has no enrolled face templates.
|
||||||
#[error("User not enrolled: {0}")]
|
#[error("User not enrolled: {0}")]
|
||||||
UserNotEnrolled(String),
|
UserNotEnrolled(String),
|
||||||
|
|
||||||
|
/// File system or network I/O error.
|
||||||
#[error("IO error: {0}")]
|
#[error("IO error: {0}")]
|
||||||
Io(#[from] std::io::Error),
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
/// JSON/TOML serialization or deserialization error.
|
||||||
#[error("Serialization error: {0}")]
|
#[error("Serialization error: {0}")]
|
||||||
Serialization(String),
|
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>;
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1,7 +1,52 @@
|
|||||||
//! Linux Hello Common Library
|
//! Linux Hello Common Library
|
||||||
//!
|
//!
|
||||||
//! Shared types, configuration, and error handling for the Linux Hello
|
//! This crate provides shared types, configuration, and error handling for the
|
||||||
//! facial authentication system.
|
//! 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 config;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
@@ -9,4 +54,4 @@ pub mod template;
|
|||||||
|
|
||||||
pub use config::Config;
|
pub use config::Config;
|
||||||
pub use error::{Error, Result};
|
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.
|
//! This module handles storage and retrieval of face templates (embeddings) for
|
||||||
//! Currently uses unencrypted file-based storage. TPM encryption will be added in Phase 3.
|
//! 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 serde::{Deserialize, Serialize};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -9,57 +59,131 @@ use std::fs;
|
|||||||
|
|
||||||
use crate::error::{Error, Result};
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct FaceTemplate {
|
pub struct FaceTemplate {
|
||||||
/// User identifier
|
/// System username this template belongs to.
|
||||||
pub user: String,
|
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,
|
pub label: String,
|
||||||
/// Face embedding vector (normalized)
|
/// Normalized face embedding vector.
|
||||||
|
/// Typically 128 dimensions (MobileFaceNet) or 512 dimensions (ArcFace).
|
||||||
pub embedding: Vec<f32>,
|
pub embedding: Vec<f32>,
|
||||||
/// Timestamp when enrolled
|
/// Unix timestamp when this template was enrolled.
|
||||||
pub enrolled_at: u64,
|
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,
|
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 {
|
pub struct TemplateStore {
|
||||||
/// Base directory for template storage
|
/// Base directory for template storage
|
||||||
base_path: PathBuf,
|
base_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TemplateStore {
|
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 {
|
pub fn new<P: AsRef<Path>>(base_path: P) -> Self {
|
||||||
Self {
|
Self {
|
||||||
base_path: base_path.as_ref().to_path_buf(),
|
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 {
|
pub fn default_path() -> PathBuf {
|
||||||
PathBuf::from("/var/lib/linux-hello/templates")
|
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<()> {
|
pub fn initialize(&self) -> Result<()> {
|
||||||
fs::create_dir_all(&self.base_path)?;
|
fs::create_dir_all(&self.base_path)?;
|
||||||
Ok(())
|
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 {
|
fn user_path(&self, user: &str) -> PathBuf {
|
||||||
self.base_path.join(user)
|
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 {
|
fn template_path(&self, user: &str, label: &str) -> PathBuf {
|
||||||
self.user_path(user).join(format!("{}.json", label))
|
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<()> {
|
pub fn store(&self, template: &FaceTemplate) -> Result<()> {
|
||||||
let user_dir = self.user_path(&template.user);
|
let user_dir = self.user_path(&template.user);
|
||||||
fs::create_dir_all(&user_dir)?;
|
fs::create_dir_all(&user_dir)?;
|
||||||
@@ -208,7 +332,6 @@ impl TemplateStore {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::fs;
|
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ path = "src/main.rs"
|
|||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
tpm = ["tss-esapi"]
|
tpm = ["tss-esapi"]
|
||||||
|
onnx = ["ort", "ndarray"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
linux-hello-common = { path = "../linux-hello-common" }
|
linux-hello-common = { path = "../linux-hello-common" }
|
||||||
@@ -33,13 +34,23 @@ image.workspace = true
|
|||||||
# Security
|
# Security
|
||||||
zeroize.workspace = true
|
zeroize.workspace = true
|
||||||
libc = "0.2"
|
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)
|
# TPM2 (optional)
|
||||||
tss-esapi = { workspace = true, optional = true }
|
tss-esapi = { workspace = true, optional = true }
|
||||||
|
|
||||||
# ML inference - temporarily disabled until ort 2.0 stable
|
# ML inference (enabled via 'onnx' feature)
|
||||||
# ort.workspace = true
|
ort = { workspace = true, optional = true }
|
||||||
# ndarray.workspace = true
|
ndarray = { workspace = true, optional = true }
|
||||||
|
|
||||||
# Camera (Linux-only)
|
# Camera (Linux-only)
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
@@ -47,3 +58,8 @@ v4l.workspace = true
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
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
|
//! Anti-Spoofing Module
|
||||||
//!
|
//!
|
||||||
//! Provides liveness detection to prevent authentication attacks
|
//! This module provides liveness detection to prevent authentication attacks
|
||||||
//! using photos, videos, or masks.
|
//! using photos, videos, or masks. It is a critical security component.
|
||||||
//!
|
//!
|
||||||
//! Detection methods:
|
//! # Overview
|
||||||
//! - 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
|
|
||||||
//!
|
//!
|
||||||
//! # 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
|
//! 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 linux_hello_common::Result;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ mod tests {
|
|||||||
let controls = scan_emitter_controls("/dev/video0");
|
let controls = scan_emitter_controls("/dev/video0");
|
||||||
// On non-Linux, should return mock control
|
// On non-Linux, should return mock control
|
||||||
// On Linux, depends on hardware
|
// 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
|
//! 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")]
|
#[cfg(target_os = "linux")]
|
||||||
mod linux;
|
mod linux;
|
||||||
@@ -14,17 +57,38 @@ pub use linux::*;
|
|||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use ir_emitter::IrEmitterControl;
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
#[allow(dead_code)] // Public API, fields may be used by external code
|
#[allow(dead_code)] // Public API, fields may be used by external code
|
||||||
pub struct CameraInfo {
|
pub struct CameraInfo {
|
||||||
/// Device path (e.g., /dev/video0)
|
/// Device path (e.g., "/dev/video0" on Linux).
|
||||||
pub device_path: String,
|
pub device_path: String,
|
||||||
/// Human-readable name
|
/// Human-readable camera name from the driver.
|
||||||
pub name: String,
|
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,
|
pub is_ir: bool,
|
||||||
/// Supported resolutions
|
/// List of supported resolutions as (width, height) pairs.
|
||||||
pub resolutions: Vec<(u32, u32)>,
|
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)]
|
#[derive(Debug)]
|
||||||
#[allow(dead_code)] // Public API, used by camera implementations
|
#[allow(dead_code)] // Public API, used by camera implementations
|
||||||
pub struct Frame {
|
pub struct Frame {
|
||||||
/// Raw frame data
|
/// Raw pixel data in the specified format.
|
||||||
pub data: Vec<u8>,
|
pub data: Vec<u8>,
|
||||||
/// Frame width in pixels
|
/// Frame width in pixels.
|
||||||
pub width: u32,
|
pub width: u32,
|
||||||
/// Frame height in pixels
|
/// Frame height in pixels.
|
||||||
pub height: u32,
|
pub height: u32,
|
||||||
/// Pixel format
|
/// Pixel format of the data.
|
||||||
pub format: PixelFormat,
|
pub format: PixelFormat,
|
||||||
/// Timestamp in microseconds
|
/// Timestamp in microseconds since capture start.
|
||||||
|
/// Useful for temporal analysis and frame timing.
|
||||||
pub timestamp_us: u64,
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
#[allow(dead_code)] // Public API, variants used by camera implementations
|
#[allow(dead_code)] // Public API, variants used by camera implementations
|
||||||
pub enum PixelFormat {
|
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,
|
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,
|
Yuyv,
|
||||||
/// MJPEG compressed
|
/// MJPEG compressed.
|
||||||
|
/// Variable-length JPEG frames. Requires decompression before processing.
|
||||||
Mjpeg,
|
Mjpeg,
|
||||||
/// Unknown format
|
/// Unknown or unsupported format.
|
||||||
|
/// Frames with this format cannot be processed.
|
||||||
Unknown,
|
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 Module
|
||||||
//!
|
//!
|
||||||
//! Face detection types and simple fallback detection.
|
//! This module provides face detection functionality for the authentication pipeline.
|
||||||
//! ONNX-based detection will be added once models are available.
|
//! 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;
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
#[allow(dead_code)] // Public API, fields used by detection methods
|
#[allow(dead_code)] // Public API, fields used by detection methods
|
||||||
pub struct FaceDetection {
|
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,
|
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,
|
pub y: f32,
|
||||||
/// Width of bounding box (0-1 normalized)
|
/// Width of bounding box (0.0-1.0 normalized).
|
||||||
pub width: f32,
|
pub width: f32,
|
||||||
/// Height of bounding box (0-1 normalized)
|
/// Height of bounding box (0.0-1.0 normalized).
|
||||||
pub height: f32,
|
pub height: f32,
|
||||||
/// Detection confidence score (0-1)
|
/// Detection confidence score (0.0-1.0).
|
||||||
|
/// Higher values indicate more confident detections.
|
||||||
pub confidence: f32,
|
pub confidence: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FaceDetection {
|
impl FaceDetection {
|
||||||
/// Convert normalized coordinates to pixel coordinates
|
/// Convert normalized coordinates to pixel coordinates.
|
||||||
///
|
///
|
||||||
/// Public API - used by face region extraction
|
/// # 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
|
#[allow(dead_code)] // Public API, used by auth service
|
||||||
pub fn to_pixels(&self, img_width: u32, img_height: u32) -> (u32, u32, u32, u32) {
|
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;
|
let x = (self.x * img_width as f32) as u32;
|
||||||
@@ -35,17 +115,56 @@ impl FaceDetection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Face detector trait for different backends
|
/// Trait for face detection backends.
|
||||||
///
|
///
|
||||||
/// Public API - trait for extensible 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
|
#[allow(dead_code)] // Public API trait
|
||||||
pub trait FaceDetect {
|
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>>;
|
fn detect(&self, image_data: &[u8], width: u32, height: u32) -> Result<Vec<FaceDetection>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simple face detection using image processing (no ML)
|
/// Simple face detection using basic image analysis.
|
||||||
/// Used as fallback or for testing
|
///
|
||||||
|
/// 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> {
|
pub fn detect_face_simple(image_data: &[u8], _width: u32, _height: u32) -> Option<FaceDetection> {
|
||||||
// Very simple centered face assumption for testing
|
// Very simple centered face assumption for testing
|
||||||
// In production, this would use proper CV techniques
|
// 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
|
//! This module extracts face embeddings (feature vectors) from detected faces.
|
||||||
//! implementation. ONNX model integration will be added when models are available.
|
//! 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 linux_hello_common::Result;
|
||||||
use image::GrayImage;
|
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 {
|
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>>;
|
fn extract(&self, face_image: &GrayImage) -> Result<Vec<f32>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Placeholder embedding extractor
|
/// Placeholder embedding extractor for testing.
|
||||||
///
|
///
|
||||||
/// Uses simple image statistics as a placeholder embedding.
|
/// Uses simple image statistics to generate a pseudo-embedding.
|
||||||
/// In production, this would use an ONNX model (MobileFaceNet, ArcFace, etc.)
|
/// **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)]
|
#[derive(Clone)]
|
||||||
pub struct PlaceholderEmbeddingExtractor {
|
pub struct PlaceholderEmbeddingExtractor {
|
||||||
/// Embedding dimension
|
/// Output embedding dimension (typically 128 or 512).
|
||||||
pub dimension: usize,
|
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 {
|
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
||||||
if a.len() != b.len() {
|
if a.len() != b.len() {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
@@ -118,7 +235,31 @@ pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
|||||||
dot_product / (norm_a * norm_b)
|
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 {
|
pub fn euclidean_distance(a: &[f32], b: &[f32]) -> f32 {
|
||||||
if a.len() != b.len() {
|
if a.len() != b.len() {
|
||||||
return f32::MAX;
|
return f32::MAX;
|
||||||
@@ -136,10 +277,34 @@ pub fn euclidean_distance(a: &[f32], b: &[f32]) -> f32 {
|
|||||||
sum_sq_diff.sqrt()
|
sum_sq_diff.sqrt()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert cosine similarity to distance (for thresholding)
|
/// Convert cosine similarity to distance (for thresholding).
|
||||||
///
|
///
|
||||||
/// Cosine similarity ranges from -1 to 1, where 1 is identical.
|
/// Cosine similarity ranges from -1 to 1, where 1 means identical.
|
||||||
/// This converts it to a distance metric where 0 is 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 {
|
pub fn similarity_to_distance(similarity: f32) -> f32 {
|
||||||
1.0 - similarity
|
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 serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
use tokio::net::{UnixListener, UnixStream};
|
use tokio::net::{UnixListener, UnixStream};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
use linux_hello_common::error::{Error, Result};
|
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
|
/// IPC message types
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
#[serde(tag = "action")]
|
#[serde(tag = "action")]
|
||||||
@@ -75,6 +302,7 @@ pub struct IpcServer {
|
|||||||
enroll_handler: Option<EnrollHandler>,
|
enroll_handler: Option<EnrollHandler>,
|
||||||
list_handler: Option<ListHandler>,
|
list_handler: Option<ListHandler>,
|
||||||
remove_handler: Option<RemoveHandler>,
|
remove_handler: Option<RemoveHandler>,
|
||||||
|
rate_limiter: Arc<Mutex<RateLimiter>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IpcServer {
|
impl IpcServer {
|
||||||
@@ -86,6 +314,7 @@ impl IpcServer {
|
|||||||
enroll_handler: None,
|
enroll_handler: None,
|
||||||
list_handler: None,
|
list_handler: None,
|
||||||
remove_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)?;
|
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)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
let mut perms = std::fs::metadata(&self.socket_path)?.permissions();
|
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)?;
|
std::fs::set_permissions(&self.socket_path, perms)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,10 +383,32 @@ impl IpcServer {
|
|||||||
loop {
|
loop {
|
||||||
match listener.accept().await {
|
match listener.accept().await {
|
||||||
Ok((stream, _addr)) => {
|
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 auth_handler = self.auth_handler.clone();
|
||||||
let enroll_handler = self.enroll_handler.clone();
|
let enroll_handler = self.enroll_handler.clone();
|
||||||
let list_handler = self.list_handler.clone();
|
let list_handler = self.list_handler.clone();
|
||||||
let remove_handler = self.remove_handler.clone();
|
let remove_handler = self.remove_handler.clone();
|
||||||
|
let rate_limiter = self.rate_limiter.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = Self::handle_client(
|
if let Err(e) = Self::handle_client(
|
||||||
stream,
|
stream,
|
||||||
@@ -164,6 +416,8 @@ impl IpcServer {
|
|||||||
enroll_handler,
|
enroll_handler,
|
||||||
list_handler,
|
list_handler,
|
||||||
remove_handler,
|
remove_handler,
|
||||||
|
peer_creds,
|
||||||
|
rate_limiter,
|
||||||
).await {
|
).await {
|
||||||
warn!("Error handling client: {}", e);
|
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(
|
async fn handle_client(
|
||||||
mut stream: UnixStream,
|
mut stream: UnixStream,
|
||||||
auth_handler: Option<AuthHandler>,
|
auth_handler: Option<AuthHandler>,
|
||||||
enroll_handler: Option<EnrollHandler>,
|
enroll_handler: Option<EnrollHandler>,
|
||||||
list_handler: Option<ListHandler>,
|
list_handler: Option<ListHandler>,
|
||||||
remove_handler: Option<RemoveHandler>,
|
remove_handler: Option<RemoveHandler>,
|
||||||
|
peer_creds: PeerCredentials,
|
||||||
|
rate_limiter: Arc<Mutex<RateLimiter>>,
|
||||||
) -> Result<()> {
|
) -> 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?;
|
let n = stream.read(&mut buffer).await?;
|
||||||
|
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
return Ok(()); // Connection closed
|
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_str = String::from_utf8_lossy(&buffer[..n]);
|
||||||
let request: IpcRequest = serde_json::from_str(&request_str)
|
let request: IpcRequest = serde_json::from_str(&request_str)
|
||||||
.map_err(|e| Error::Serialization(format!("Invalid request: {}", e)))?;
|
.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 {
|
let response = match request {
|
||||||
IpcRequest::Authenticate { user } => {
|
IpcRequest::Authenticate { user } => {
|
||||||
|
// Authentication requests are allowed from any authenticated connection
|
||||||
|
// The PAM module runs as root when performing authentication
|
||||||
match auth_handler {
|
match auth_handler {
|
||||||
Some(ref h) => {
|
Some(ref h) => {
|
||||||
match h(user).await {
|
match h(user.clone()).await {
|
||||||
Ok(true) => IpcResponse {
|
Ok(true) => IpcResponse {
|
||||||
success: true,
|
success: true,
|
||||||
message: Some("Authentication successful".to_string()),
|
message: Some("Authentication successful".to_string()),
|
||||||
confidence: Some(1.0),
|
confidence: Some(1.0),
|
||||||
templates: None,
|
templates: None,
|
||||||
},
|
},
|
||||||
Ok(false) => IpcResponse {
|
Ok(false) => {
|
||||||
success: false,
|
// Record failed authentication for rate limiting
|
||||||
message: Some("Authentication failed".to_string()),
|
let mut limiter = rate_limiter.lock().await;
|
||||||
confidence: None,
|
limiter.record_failure(peer_creds.uid);
|
||||||
templates: None,
|
IpcResponse {
|
||||||
},
|
success: false,
|
||||||
Err(e) => IpcResponse {
|
message: Some("Authentication failed".to_string()),
|
||||||
success: false,
|
confidence: None,
|
||||||
message: Some(format!("Error: {}", e)),
|
templates: None,
|
||||||
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 {
|
None => IpcResponse {
|
||||||
@@ -228,81 +540,147 @@ impl IpcServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
IpcRequest::Enroll { user, label, frame_count } => {
|
IpcRequest::Enroll { user, label, frame_count } => {
|
||||||
match enroll_handler {
|
// SECURITY: Authorization check for enrollment
|
||||||
Some(ref h) => {
|
// Only root or the user themselves can enroll faces
|
||||||
match h(user.clone(), label.clone(), frame_count).await {
|
if !peer_creds.can_operate_on_user(&user) {
|
||||||
Ok(()) => IpcResponse {
|
warn!(
|
||||||
success: true,
|
"Unauthorized enrollment attempt: UID {} tried to enroll user '{}'",
|
||||||
message: Some(format!("Enrollment successful for user: {}", user)),
|
peer_creds.uid, user
|
||||||
confidence: None,
|
);
|
||||||
templates: None,
|
IpcResponse {
|
||||||
},
|
|
||||||
Err(e) => IpcResponse {
|
|
||||||
success: false,
|
|
||||||
message: Some(format!("Enrollment failed: {}", e)),
|
|
||||||
confidence: None,
|
|
||||||
templates: None,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => IpcResponse {
|
|
||||||
success: false,
|
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,
|
confidence: None,
|
||||||
templates: 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 } => {
|
IpcRequest::List { user } => {
|
||||||
match list_handler {
|
// SECURITY: Authorization check for listing templates
|
||||||
Some(ref h) => {
|
// Only root or the user themselves can list their templates
|
||||||
match h(user).await {
|
if !peer_creds.can_operate_on_user(&user) {
|
||||||
Ok(templates) => IpcResponse {
|
warn!(
|
||||||
success: true,
|
"Unauthorized list attempt: UID {} tried to list templates for user '{}'",
|
||||||
message: None,
|
peer_creds.uid, user
|
||||||
confidence: None,
|
);
|
||||||
templates: Some(templates),
|
IpcResponse {
|
||||||
},
|
|
||||||
Err(e) => IpcResponse {
|
|
||||||
success: false,
|
|
||||||
message: Some(format!("Error: {}", e)),
|
|
||||||
confidence: None,
|
|
||||||
templates: None,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => IpcResponse {
|
|
||||||
success: false,
|
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,
|
confidence: None,
|
||||||
templates: 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 } => {
|
IpcRequest::Remove { user, label, all } => {
|
||||||
match remove_handler {
|
// SECURITY: Authorization check for template removal
|
||||||
Some(ref h) => {
|
// Only root or the user themselves can remove their templates
|
||||||
match h(user.clone(), label, all).await {
|
if !peer_creds.can_operate_on_user(&user) {
|
||||||
Ok(()) => IpcResponse {
|
warn!(
|
||||||
success: true,
|
"Unauthorized remove attempt: UID {} tried to remove templates for user '{}'",
|
||||||
message: Some(format!("Templates removed for user: {}", user)),
|
peer_creds.uid, user
|
||||||
confidence: None,
|
);
|
||||||
templates: None,
|
IpcResponse {
|
||||||
},
|
|
||||||
Err(e) => IpcResponse {
|
|
||||||
success: false,
|
|
||||||
message: Some(format!("Error: {}", e)),
|
|
||||||
confidence: None,
|
|
||||||
templates: None,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => IpcResponse {
|
|
||||||
success: false,
|
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,
|
confidence: None,
|
||||||
templates: 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 {
|
IpcRequest::Ping => IpcResponse {
|
||||||
@@ -315,7 +693,7 @@ impl IpcServer {
|
|||||||
|
|
||||||
let response_json = serde_json::to_string(&response)
|
let response_json = serde_json::to_string(&response)
|
||||||
.map_err(|e| Error::Serialization(e.to_string()))?;
|
.map_err(|e| Error::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
stream.write_all(response_json.as_bytes()).await?;
|
stream.write_all(response_json.as_bytes()).await?;
|
||||||
stream.flush().await?;
|
stream.flush().await?;
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,87 @@
|
|||||||
//! Linux Hello Daemon Library
|
//! Linux Hello Daemon Library
|
||||||
//!
|
//!
|
||||||
//! Core functionality for camera capture and face detection.
|
//! This crate provides the core functionality for the Linux Hello facial
|
||||||
//! Re-exported for use by the CLI tool.
|
//! 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 anti_spoofing;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod camera;
|
pub mod camera;
|
||||||
|
pub mod dbus_server;
|
||||||
|
pub mod dbus_service;
|
||||||
pub mod detection;
|
pub mod detection;
|
||||||
pub mod embedding;
|
pub mod embedding;
|
||||||
pub mod ipc;
|
pub mod ipc;
|
||||||
@@ -14,18 +90,76 @@ pub mod secure_memory;
|
|||||||
pub mod secure_template_store;
|
pub mod secure_template_store;
|
||||||
pub mod tpm;
|
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};
|
pub use anti_spoofing::{AntiSpoofingConfig, AntiSpoofingDetector, LivenessResult};
|
||||||
|
|
||||||
|
// Re-export secure memory types
|
||||||
pub use secure_memory::{SecureBytes, SecureEmbedding};
|
pub use secure_memory::{SecureBytes, SecureEmbedding};
|
||||||
|
|
||||||
|
// Re-export secure template store
|
||||||
pub use secure_template_store::SecureTemplateStore;
|
pub use secure_template_store::SecureTemplateStore;
|
||||||
|
|
||||||
|
// Re-export camera types
|
||||||
pub use camera::{CameraInfo, Frame, PixelFormat};
|
pub use camera::{CameraInfo, Frame, PixelFormat};
|
||||||
|
|
||||||
|
// Re-export detection types
|
||||||
pub use detection::{FaceDetection, FaceDetect, detect_face_simple, SimpleFaceDetector};
|
pub use detection::{FaceDetection, FaceDetect, detect_face_simple, SimpleFaceDetector};
|
||||||
|
|
||||||
|
// Re-export embedding types and functions
|
||||||
pub use embedding::{
|
pub use embedding::{
|
||||||
cosine_similarity, euclidean_distance, EmbeddingExtractor, PlaceholderEmbeddingExtractor,
|
cosine_similarity, euclidean_distance, EmbeddingExtractor, PlaceholderEmbeddingExtractor,
|
||||||
similarity_to_distance,
|
similarity_to_distance,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Re-export matching types and functions
|
||||||
pub use matching::{average_embeddings, match_template, MatchResult};
|
pub use matching::{average_embeddings, match_template, MatchResult};
|
||||||
|
|
||||||
|
// Re-export IPC types
|
||||||
pub use ipc::{IpcClient, IpcRequest, IpcResponse, IpcServer};
|
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")]
|
#[cfg(target_os = "linux")]
|
||||||
pub use camera::{enumerate_cameras, Camera};
|
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,
|
//! Main daemon process for face authentication. Handles camera capture,
|
||||||
//! face detection, anti-spoofing checks, and template matching.
|
//! 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 camera;
|
||||||
mod detection;
|
mod detection;
|
||||||
|
|
||||||
use linux_hello_common::{Config, Result, TemplateStore};
|
use linux_hello_common::{Config, Result, TemplateStore};
|
||||||
use linux_hello_daemon::auth::AuthService;
|
use linux_hello_daemon::auth::AuthService;
|
||||||
|
use linux_hello_daemon::dbus_server::{DbusServer, check_system_bus_available};
|
||||||
use linux_hello_daemon::ipc::IpcServer;
|
use linux_hello_daemon::ipc::IpcServer;
|
||||||
use tracing::{error, info, Level};
|
use tracing::{error, info, warn, Level};
|
||||||
use tracing_subscriber::FmtSubscriber;
|
use tracing_subscriber::FmtSubscriber;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -40,7 +45,7 @@ async fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("Camera enumeration failed: {}", e);
|
warn!("Camera enumeration failed: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,7 +66,7 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
// Start IPC server
|
// Start IPC server
|
||||||
let mut ipc_server = IpcServer::new(IpcServer::default_socket_path());
|
let mut ipc_server = IpcServer::new(IpcServer::default_socket_path());
|
||||||
|
|
||||||
// Set authentication handler
|
// Set authentication handler
|
||||||
let auth_service_for_auth = auth_service.clone();
|
let auth_service_for_auth = auth_service.clone();
|
||||||
ipc_server.set_auth_handler(move |user| {
|
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!("Linux Hello Daemon ready");
|
||||||
info!("Listening for authentication requests...");
|
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();
|
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::select! {
|
||||||
_ = tokio::signal::ctrl_c() => {
|
_ = tokio::signal::ctrl_c() => {
|
||||||
info!("Shutdown signal received");
|
info!("Shutdown signal received");
|
||||||
|
|||||||
@@ -1,24 +1,111 @@
|
|||||||
//! Template Matching Module
|
//! 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 linux_hello_common::{FaceTemplate, Result};
|
||||||
use crate::embedding::{cosine_similarity, similarity_to_distance};
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct MatchResult {
|
pub struct MatchResult {
|
||||||
/// Whether a match was found
|
/// Whether the probe matched any stored template.
|
||||||
pub matched: bool,
|
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,
|
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,
|
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>,
|
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(
|
pub fn match_template(
|
||||||
embedding: &[f32],
|
embedding: &[f32],
|
||||||
templates: &[FaceTemplate],
|
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>> {
|
pub fn average_embeddings(embeddings: &[Vec<f32>]) -> Result<Vec<f32>> {
|
||||||
if embeddings.is_empty() {
|
if embeddings.is_empty() {
|
||||||
return Err(linux_hello_common::Error::Detection(
|
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
|
//! Secure Memory Module
|
||||||
//!
|
//!
|
||||||
//! Provides secure handling of sensitive data like embeddings
|
//! This module provides secure handling of sensitive biometric data like face
|
||||||
//! and templates. Key features:
|
//! embeddings and templates. It implements defense-in-depth memory protection.
|
||||||
//!
|
//!
|
||||||
//! - Automatic zeroization on drop
|
//! # Security Features
|
||||||
//! - Memory locking to prevent swapping
|
//!
|
||||||
//! - Secure comparison (constant-time where possible)
|
//! | Feature | Description |
|
||||||
//! - Protection against memory dumps
|
//! |---------|-------------|
|
||||||
|
//! | 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 linux_hello_common::{Error, Result};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
use subtle::{Choice, ConstantTimeEq};
|
||||||
use zeroize::{Zeroize, ZeroizeOnDrop};
|
use zeroize::{Zeroize, ZeroizeOnDrop};
|
||||||
|
|
||||||
/// Secure container for face embedding data
|
/// Secure container for face embedding data
|
||||||
@@ -23,8 +59,38 @@ pub struct SecureEmbedding {
|
|||||||
|
|
||||||
impl SecureEmbedding {
|
impl SecureEmbedding {
|
||||||
/// Create a new secure embedding from raw data
|
/// Create a new secure embedding from raw data
|
||||||
|
///
|
||||||
|
/// Attempts to lock the memory to prevent swapping.
|
||||||
pub fn new(data: Vec<f32>) -> Self {
|
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
|
/// Get the embedding dimension
|
||||||
@@ -46,10 +112,20 @@ impl SecureEmbedding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate cosine similarity with another embedding
|
/// Calculate cosine similarity with another embedding
|
||||||
///
|
///
|
||||||
/// Returns a value between -1.0 and 1.0
|
/// 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 {
|
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;
|
return 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,14 +133,29 @@ impl SecureEmbedding {
|
|||||||
let mut norm_a = 0.0f32;
|
let mut norm_a = 0.0f32;
|
||||||
let mut norm_b = 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;
|
dot_product += a * b;
|
||||||
norm_a += a * a;
|
norm_a += a * a;
|
||||||
norm_b += b * b;
|
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);
|
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
|
/// Calculate Euclidean distance with another embedding
|
||||||
@@ -144,16 +235,44 @@ impl SecureBytes {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Constant-time comparison to prevent timing attacks
|
/// 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 {
|
pub fn constant_time_eq(&self, other: &SecureBytes) -> bool {
|
||||||
if self.len() != other.len() {
|
let self_len = self.len();
|
||||||
return false;
|
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;
|
// Both conditions must be true:
|
||||||
for (a, b) in self.data.iter().zip(other.data.iter()) {
|
// 1. Lengths must match (constant-time check)
|
||||||
result |= a ^ b;
|
// 2. All bytes must be equal (constant-time accumulation)
|
||||||
}
|
let bytes_match: Choice = differences.ct_eq(&0u8);
|
||||||
result == 0
|
|
||||||
|
// 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 bytes1 = SecureBytes::new(vec![1, 2, 3, 4]);
|
||||||
let bytes2 = 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]);
|
let bytes3 = SecureBytes::new(vec![1, 2, 3, 5]);
|
||||||
|
|
||||||
assert!(bytes1.constant_time_eq(&bytes2));
|
assert!(bytes1.constant_time_eq(&bytes2));
|
||||||
assert!(!bytes1.constant_time_eq(&bytes3));
|
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]
|
#[test]
|
||||||
fn test_secure_zero() {
|
fn test_secure_zero() {
|
||||||
let mut data = vec![1u8, 2, 3, 4, 5];
|
let mut data = vec![1u8, 2, 3, 4, 5];
|
||||||
|
|||||||
@@ -1,16 +1,59 @@
|
|||||||
//! TPM2 Storage Module
|
//! TPM2 Storage Module
|
||||||
//!
|
//!
|
||||||
//! Provides secure storage for face templates using TPM2.
|
//! This module provides secure storage for face templates using TPM2 (Trusted
|
||||||
//! Templates are encrypted with TPM-bound keys, making them
|
//! Platform Module). Templates are encrypted with hardware-bound keys, making
|
||||||
//! inaccessible without the specific TPM hardware.
|
//! 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
|
//! ```text
|
||||||
//! TPM Storage Root Key (SRK)
|
//! TPM Storage Root Key (SRK)
|
||||||
//! └── Linux Hello Primary Key (sealed to PCRs)
|
//! └── Linux Hello Primary Key (sealed to PCRs)
|
||||||
//! └── User Template Encryption Key (per-user)
|
//! └── User Template Encryption Key (per-user)
|
||||||
//! └── Encrypted face template
|
//! └── 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 linux_hello_common::{Error, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -25,10 +68,12 @@ pub const PRIMARY_KEY_HANDLE: u32 = 0x81000001;
|
|||||||
/// Encrypted template data structure
|
/// Encrypted template data structure
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct EncryptedTemplate {
|
pub struct EncryptedTemplate {
|
||||||
/// Encrypted embedding data
|
/// Encrypted embedding data (includes AES-GCM authentication tag)
|
||||||
pub ciphertext: Vec<u8>,
|
pub ciphertext: Vec<u8>,
|
||||||
/// Initialization vector
|
/// Initialization vector (nonce) - 12 bytes for AES-GCM
|
||||||
pub iv: Vec<u8>,
|
pub iv: Vec<u8>,
|
||||||
|
/// Salt used for key derivation - 32 bytes
|
||||||
|
pub salt: Vec<u8>,
|
||||||
/// Key handle used for encryption
|
/// Key handle used for encryption
|
||||||
pub key_handle: u32,
|
pub key_handle: u32,
|
||||||
/// Whether this template is TPM-encrypted
|
/// Whether this template is TPM-encrypted
|
||||||
@@ -56,14 +101,27 @@ pub trait TpmStorage {
|
|||||||
fn remove_user_key(&mut self, user: &str) -> Result<()>;
|
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)
|
/// Software-only fallback encryption (when TPM is not available)
|
||||||
///
|
///
|
||||||
/// WARNING: This is NOT secure for production use. It provides
|
/// This implementation uses cryptographically secure algorithms:
|
||||||
/// basic encryption for development/testing without TPM hardware.
|
/// - AES-256-GCM for authenticated encryption
|
||||||
|
/// - PBKDF2-HMAC-SHA256 for key derivation
|
||||||
|
/// - Cryptographically secure random number generation for IV and salt
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct SoftwareTpmFallback {
|
pub struct SoftwareTpmFallback {
|
||||||
key_path: PathBuf,
|
key_path: PathBuf,
|
||||||
initialized: bool,
|
initialized: bool,
|
||||||
|
/// Master secret loaded from key file (used as password input to PBKDF2)
|
||||||
|
master_secret: Option<Vec<u8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SoftwareTpmFallback {
|
impl SoftwareTpmFallback {
|
||||||
@@ -71,6 +129,7 @@ impl SoftwareTpmFallback {
|
|||||||
Self {
|
Self {
|
||||||
key_path: key_path.as_ref().to_path_buf(),
|
key_path: key_path.as_ref().to_path_buf(),
|
||||||
initialized: false,
|
initialized: false,
|
||||||
|
master_secret: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,30 +142,111 @@ impl SoftwareTpmFallback {
|
|||||||
self.key_path.join(format!("{}.key", user))
|
self.key_path.join(format!("{}.key", user))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simple XOR-based encryption (placeholder - NOT SECURE)
|
/// Get path to master secret file
|
||||||
/// In production, use proper AES-GCM with derived keys
|
fn master_secret_path(&self) -> PathBuf {
|
||||||
fn xor_encrypt(&self, data: &[u8], key: &[u8]) -> Vec<u8> {
|
self.key_path.join("master.secret")
|
||||||
data.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, &byte)| byte ^ key[i % key.len()])
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate a pseudo-random key from user identifier
|
/// Generate or load the master secret
|
||||||
/// WARNING: This is NOT cryptographically secure
|
fn ensure_master_secret(&mut self) -> Result<()> {
|
||||||
fn derive_key(&self, user: &str) -> Vec<u8> {
|
use rand::RngCore;
|
||||||
use std::collections::hash_map::DefaultHasher;
|
|
||||||
use std::hash::{Hash, Hasher};
|
if self.master_secret.is_some() {
|
||||||
|
return Ok(());
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
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<()> {
|
fn initialize(&mut self) -> Result<()> {
|
||||||
std::fs::create_dir_all(&self.key_path)?;
|
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;
|
self.initialized = true;
|
||||||
warn!("Using software TPM fallback - templates are NOT securely encrypted");
|
warn!("Using software TPM fallback - no hardware TPM protection");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn encrypt(&self, user: &str, plaintext: &[u8]) -> Result<EncryptedTemplate> {
|
fn encrypt(&self, user: &str, plaintext: &[u8]) -> Result<EncryptedTemplate> {
|
||||||
let key = self.derive_key(user);
|
// Generate cryptographically secure random salt and nonce
|
||||||
let iv: Vec<u8> = (0..16).map(|i| ((i * 17 + 42) % 256) as u8).collect();
|
let salt: [u8; SALT_SIZE] = Self::generate_random_bytes();
|
||||||
|
let nonce: [u8; NONCE_SIZE] = Self::generate_random_bytes();
|
||||||
// XOR with key (NOT SECURE - placeholder only)
|
|
||||||
let ciphertext = self.xor_encrypt(plaintext, &key);
|
// 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 {
|
Ok(EncryptedTemplate {
|
||||||
ciphertext,
|
ciphertext,
|
||||||
iv,
|
iv: nonce.to_vec(),
|
||||||
|
salt: salt.to_vec(),
|
||||||
key_handle: 0, // No TPM handle for software fallback
|
key_handle: 0, // No TPM handle for software fallback
|
||||||
tpm_encrypted: false,
|
tpm_encrypted: false,
|
||||||
})
|
})
|
||||||
@@ -144,27 +301,53 @@ impl TpmStorage for SoftwareTpmFallback {
|
|||||||
"Cannot decrypt TPM-encrypted template without TPM".to_string(),
|
"Cannot decrypt TPM-encrypted template without TPM".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let key = self.derive_key(user);
|
// Validate salt and IV sizes
|
||||||
let plaintext = self.xor_encrypt(&encrypted.ciphertext, &key);
|
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)
|
Ok(plaintext)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_user_key(&mut self, user: &str) -> Result<()> {
|
fn create_user_key(&mut self, user: &str) -> Result<()> {
|
||||||
let key_path = self.user_key_path(user);
|
let key_path = self.user_key_path(user);
|
||||||
let _key = self.derive_key(user); // Derived but not stored in software fallback
|
|
||||||
|
// Store key metadata (the actual key is derived on-demand using PBKDF2)
|
||||||
// Store key metadata (not the actual key for software fallback)
|
let metadata = format!("user={}\ncreated={}",
|
||||||
let metadata = format!("user={}\ncreated={}", user,
|
user,
|
||||||
std::time::SystemTime::now()
|
std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.as_secs()
|
.as_secs()
|
||||||
);
|
);
|
||||||
|
|
||||||
std::fs::write(&key_path, metadata)?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,16 +550,18 @@ mod tests {
|
|||||||
fn test_encrypted_template_serialization() {
|
fn test_encrypted_template_serialization() {
|
||||||
let template = EncryptedTemplate {
|
let template = EncryptedTemplate {
|
||||||
ciphertext: vec![1, 2, 3, 4, 5],
|
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,
|
key_handle: PRIMARY_KEY_HANDLE,
|
||||||
tpm_encrypted: false,
|
tpm_encrypted: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&template).unwrap();
|
let json = serde_json::to_string(&template).unwrap();
|
||||||
let restored: EncryptedTemplate = serde_json::from_str(&json).unwrap();
|
let restored: EncryptedTemplate = serde_json::from_str(&json).unwrap();
|
||||||
|
|
||||||
assert_eq!(restored.ciphertext, template.ciphertext);
|
assert_eq!(restored.ciphertext, template.ciphertext);
|
||||||
assert_eq!(restored.iv, template.iv);
|
assert_eq!(restored.iv, template.iv);
|
||||||
|
assert_eq!(restored.salt, template.salt);
|
||||||
assert_eq!(restored.key_handle, template.key_handle);
|
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
|
## Required Models
|
||||||
|
|
||||||
### BlazeFace (Face Detection)
|
### 1. Face Detection Model
|
||||||
- **File**: `blazeface.onnx`
|
|
||||||
- **Purpose**: Fast face detection
|
|
||||||
- **Input**: RGB image [1, 3, 128, 128]
|
|
||||||
- **Output**: Bounding boxes and confidence scores
|
|
||||||
|
|
||||||
Download from: https://github.com/onnx/models/tree/main/vision/body_analysis/ultraface
|
**Recommended: RetinaFace**
|
||||||
|
|
||||||
### MobileFaceNet (Face Embedding)
|
| Property | Value |
|
||||||
- **File**: `mobilefacenet.onnx`
|
|----------|-------|
|
||||||
- **Purpose**: Face feature extraction
|
| File | `retinaface.onnx` |
|
||||||
- **Input**: Aligned face [1, 3, 112, 112]
|
| Purpose | Face detection with 5-point landmarks |
|
||||||
- **Output**: 128-dimensional embedding
|
| 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
|
```bash
|
||||||
# From TensorFlow
|
# RetinaFace (face detection)
|
||||||
python -m tf2onnx.convert --saved-model ./model --output model.onnx
|
wget https://github.com/onnx/models/raw/main/vision/body_analysis/ultraface/models/version-RFB-640.onnx \
|
||||||
|
-O retinaface.onnx
|
||||||
|
|
||||||
# From PyTorch
|
# Note: MobileFaceNet may need to be converted from other frameworks
|
||||||
import torch
|
|
||||||
torch.onnx.export(model, dummy_input, "model.onnx")
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
### Option 2: From InsightFace
|
||||||
|
|
||||||
Please ensure you comply with the licenses of any models you download:
|
```bash
|
||||||
- BlazeFace: Apache 2.0
|
# Clone InsightFace model repository
|
||||||
- MobileFaceNet: MIT
|
git clone https://github.com/deepinsight/insightface.git
|
||||||
- ArcFace: MIT
|
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;
|
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 */
|
/* Send authentication request */
|
||||||
static int send_request(int sockfd, const char *user, struct module_options *opts) {
|
static int send_request(int sockfd, const char *user, struct module_options *opts) {
|
||||||
char request[MAX_MESSAGE_SIZE];
|
char request[MAX_MESSAGE_SIZE];
|
||||||
int user_len;
|
size_t user_len;
|
||||||
int request_len;
|
int request_len;
|
||||||
ssize_t n;
|
ssize_t n;
|
||||||
|
|
||||||
/* Assertions */
|
/* Assertions */
|
||||||
assert(sockfd >= 0);
|
assert(sockfd >= 0);
|
||||||
assert(user != NULL);
|
assert(user != NULL);
|
||||||
assert(opts != NULL);
|
assert(opts != NULL);
|
||||||
|
|
||||||
/* Build request with bounds checking */
|
/* Validate username length BEFORE any operations */
|
||||||
user_len = (int)strlen(user);
|
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");
|
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);
|
"{\"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");
|
"Request buffer overflow");
|
||||||
|
|
||||||
/* Send request */
|
/* Send request */
|
||||||
n = write(sockfd, request, (size_t)request_len);
|
n = write(sockfd, request, (size_t)request_len);
|
||||||
assert_condition(n >= 0, "write result checked");
|
assert_condition(n >= 0, "write result checked");
|
||||||
|
|
||||||
if (n < 0) {
|
if (n < 0) {
|
||||||
if (opts->debug) {
|
if (opts->debug) {
|
||||||
pam_syslog(NULL, LOG_DEBUG, "Failed to send request: %s", strerror(errno));
|
pam_syslog(NULL, LOG_DEBUG, "Failed to send request: %s", strerror(errno));
|
||||||
}
|
}
|
||||||
return -1;
|
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;
|
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) {
|
static int read_response(int sockfd, struct module_options *opts) {
|
||||||
char response[MAX_MESSAGE_SIZE];
|
char response[MAX_MESSAGE_SIZE];
|
||||||
ssize_t n;
|
ssize_t n;
|
||||||
const char *success_str = "\"success\":true";
|
|
||||||
|
|
||||||
/* Assertions */
|
/* Assertions */
|
||||||
assert(sockfd >= 0);
|
assert(sockfd >= 0);
|
||||||
assert(opts != NULL);
|
assert(opts != NULL);
|
||||||
|
|
||||||
|
/* Initialize buffer to zeros for safety */
|
||||||
|
(void)memset(response, 0, sizeof(response));
|
||||||
|
|
||||||
/* Read response */
|
/* Read response */
|
||||||
n = read(sockfd, response, sizeof(response) - 1);
|
n = read(sockfd, response, sizeof(response) - 1);
|
||||||
assert_condition(n >= -1, "read result in valid range");
|
assert_condition(n >= -1, "read result in valid range");
|
||||||
|
|
||||||
if (n <= 0) {
|
if (n < 0) {
|
||||||
if (opts->debug && n < 0) {
|
if (opts->debug) {
|
||||||
pam_syslog(NULL, LOG_DEBUG, "Failed to read response: %s", strerror(errno));
|
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';
|
response[n] = '\0';
|
||||||
assert_condition(n < (ssize_t)sizeof(response), "Response fits in buffer");
|
assert_condition(n < (ssize_t)sizeof(response), "Response fits in buffer");
|
||||||
|
|
||||||
/* Simple string search for success */
|
/* Use secure JSON parser instead of naive string search */
|
||||||
if (strstr(response, success_str) != NULL) {
|
if (parse_auth_response(response, (size_t)n)) {
|
||||||
return 1; /* Success */
|
return 1; /* Success */
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0; /* Failure */
|
return 0; /* Failure */
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,14 +584,38 @@ static int authenticate_face(pam_handle_t *pamh, const char *user,
|
|||||||
struct module_options *opts) {
|
struct module_options *opts) {
|
||||||
int sockfd;
|
int sockfd;
|
||||||
int result;
|
int result;
|
||||||
|
size_t user_len;
|
||||||
/* Assertions */
|
|
||||||
|
/* Assertions - NULL checks first */
|
||||||
assert(pamh != NULL);
|
assert(pamh != NULL);
|
||||||
assert(user != NULL);
|
assert(user != NULL);
|
||||||
assert(opts != 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");
|
"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 */
|
/* Create socket */
|
||||||
sockfd = create_socket(opts);
|
sockfd = create_socket(opts);
|
||||||
if (sockfd < 0) {
|
if (sockfd < 0) {
|
||||||
@@ -241,7 +624,7 @@ static int authenticate_face(pam_handle_t *pamh, const char *user,
|
|||||||
}
|
}
|
||||||
return PAM_AUTHINFO_UNAVAIL;
|
return PAM_AUTHINFO_UNAVAIL;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Connect to daemon */
|
/* Connect to daemon */
|
||||||
result = connect_to_daemon(sockfd, opts);
|
result = connect_to_daemon(sockfd, opts);
|
||||||
if (result < 0) {
|
if (result < 0) {
|
||||||
@@ -251,23 +634,23 @@ static int authenticate_face(pam_handle_t *pamh, const char *user,
|
|||||||
(void)close(sockfd);
|
(void)close(sockfd);
|
||||||
return PAM_AUTHINFO_UNAVAIL;
|
return PAM_AUTHINFO_UNAVAIL;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Send request */
|
/* Send request */
|
||||||
result = send_request(sockfd, user, opts);
|
result = send_request(sockfd, user, opts);
|
||||||
if (result < 0) {
|
if (result < 0) {
|
||||||
(void)close(sockfd);
|
(void)close(sockfd);
|
||||||
return PAM_AUTHINFO_UNAVAIL;
|
return PAM_AUTHINFO_UNAVAIL;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Read response */
|
/* Read response */
|
||||||
result = read_response(sockfd, opts);
|
result = read_response(sockfd, opts);
|
||||||
(void)close(sockfd);
|
(void)close(sockfd);
|
||||||
|
|
||||||
if (result > 0) {
|
if (result > 0) {
|
||||||
pam_syslog(pamh, LOG_INFO, "Face authentication successful for %s", user);
|
pam_syslog(pamh, LOG_INFO, "Face authentication successful for %s", user);
|
||||||
return PAM_SUCCESS;
|
return PAM_SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts->debug) {
|
if (opts->debug) {
|
||||||
pam_syslog(pamh, LOG_DEBUG, "Face authentication failed for %s", user);
|
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.
|
//! Tests for TPM storage, secure memory, and anti-spoofing functionality.
|
||||||
|
|
||||||
use linux_hello_daemon::anti_spoofing::{
|
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::secure_memory::{SecureBytes, SecureEmbedding, memory_protection};
|
||||||
use linux_hello_daemon::tpm::{EncryptedTemplate, SoftwareTpmFallback, TpmStorage};
|
use linux_hello_daemon::tpm::{EncryptedTemplate, SoftwareTpmFallback, TpmStorage};
|
||||||
@@ -62,16 +62,18 @@ fn test_software_tpm_user_key_management() {
|
|||||||
fn test_encrypted_template_structure() {
|
fn test_encrypted_template_structure() {
|
||||||
let template = EncryptedTemplate {
|
let template = EncryptedTemplate {
|
||||||
ciphertext: vec![1, 2, 3, 4, 5, 6, 7, 8],
|
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,
|
key_handle: 0x81000001,
|
||||||
tpm_encrypted: true,
|
tpm_encrypted: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&template).unwrap();
|
let json = serde_json::to_string(&template).unwrap();
|
||||||
let restored: EncryptedTemplate = serde_json::from_str(&json).unwrap();
|
let restored: EncryptedTemplate = serde_json::from_str(&json).unwrap();
|
||||||
|
|
||||||
assert_eq!(restored.ciphertext, template.ciphertext);
|
assert_eq!(restored.ciphertext, template.ciphertext);
|
||||||
assert_eq!(restored.iv, template.iv);
|
assert_eq!(restored.iv, template.iv);
|
||||||
|
assert_eq!(restored.salt, template.salt);
|
||||||
assert_eq!(restored.key_handle, template.key_handle);
|
assert_eq!(restored.key_handle, template.key_handle);
|
||||||
assert_eq!(restored.tpm_encrypted, template.tpm_encrypted);
|
assert_eq!(restored.tpm_encrypted, template.tpm_encrypted);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user