diff --git a/assets/translations/en.json b/assets/translations/en.json index 6c754b590d..e754f704d9 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -24,6 +24,18 @@ "invalid-password": "Invalid password", "password-placeholder": "Password", "title": "Authentication Required" + }, + "face": { + "ready": "Look at the camera", + "scanning": "Scanning face…", + "no-match": "Face not recognized", + "too-dark": "Too dark for camera", + "no-face": "No face detected", + "too-close": "Move back from camera", + "too-far": "Move closer to camera", + "too-many-attempts": "Face unlock disabled (too many attempts)", + "error": "Face authentication error", + "recognized": "Face recognized" } }, "bar": { @@ -2082,6 +2094,10 @@ "button": "Toggle Editor", "description": "Add, move, and resize widgets on the lock screen", "label": "Lockscreen Widget Editor" + }, + "face-auth": { + "label": "Face Unlock", + "description": "Unlock with facial recognition via Gaze, alongside password entry" } }, "notifications": { diff --git a/meson.build b/meson.build index 935f6b5a81..378f3cb12f 100644 --- a/meson.build +++ b/meson.build @@ -405,6 +405,7 @@ _noctalia_sources = files( 'src/app/application_ui.cpp', 'src/app/main_loop.cpp', 'src/app/single_instance_lock.cpp', + 'src/auth/face_authenticator.cpp', 'src/auth/fingerprint_authenticator.cpp', 'src/auth/pam_authenticator.cpp', 'src/calendar/caldav_client.cpp', diff --git a/src/auth/face_authenticator.cpp b/src/auth/face_authenticator.cpp new file mode 100644 index 0000000000..17c17a94e2 --- /dev/null +++ b/src/auth/face_authenticator.cpp @@ -0,0 +1,317 @@ +#include "auth/face_authenticator.h" + +#include "auth/pam_authenticator.h" +#include "core/log.h" +#include "dbus/system_bus.h" +#include "i18n/i18n.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + constexpr Logger kLog("face-auth"); + + const sdbus::ServiceName kGazeBusName{"com.gundulabs.Gaze"}; + const sdbus::ObjectPath kGazePath{"/com/gundulabs/Gaze"}; + constexpr auto kGazeInterface = "com.gundulabs.Gaze"; + + const sdbus::ServiceName kLoginBusName{"org.freedesktop.login1"}; + const sdbus::ObjectPath kLoginPath{"/org/freedesktop/login1"}; + constexpr auto kLoginManagerInterface = "org.freedesktop.login1.Manager"; + + constexpr int kMaxRetries = 3; + constexpr auto kRetryDelay = std::chrono::milliseconds(500); + + struct FaceStatusMessage { + std::string key; + bool isError; + }; + + std::optional captureStatusMessage(const std::string& status) { + static const std::map kMessages = { + {"no-face", {"auth.face.no-face", false}}, + {"too-dark", {"auth.face.too-dark", false}}, + {"too-close", {"auth.face.too-close", false}}, + {"too-far", {"auth.face.too-far", false}}, + {"ready", {"auth.face.ready", false}}, + {"usable", {"auth.face.scanning", false}}, + {"not-centered", {"auth.face.no-face", false}}, + {"clipped", {"auth.face.too-close", false}}, + }; + const auto it = kMessages.find(status); + if (it == kMessages.end()) { + return std::nullopt; + } + return it->second; + } + bool isRecoverableClaimError(const sdbus::Error& error) { + const auto& name = error.getName(); + return name == sdbus::Error::Name{"com.gundulabs.Gaze.Error.ClaimDevice"} + || name == sdbus::Error::Name{"com.gundulabs.Gaze.Error.NotClaimed"}; + } +} // namespace + +FaceAuthenticator::FaceAuthenticator(SystemBus& bus) : m_bus(bus) { + m_user = PamAuthenticator::currentUsername(); + + try { + m_loginManager = sdbus::createProxy(m_bus.connection(), kLoginBusName, kLoginPath); + m_loginManager->uponSignal("PrepareForSleep").onInterface(kLoginManagerInterface).call([this](bool sleeping) { + m_sleeping = sleeping; + if (sleeping) { + stopVerify(); + } else if (m_active && !m_abort) { + startVerify(false); + } + }); + } catch (const sdbus::Error& e) { + kLog.debug("could not monitor PrepareForSleep: {}", e.what()); + m_loginManager.reset(); + } +} + +FaceAuthenticator::~FaceAuthenticator() { stop(); } + +void FaceAuthenticator::setAuthenticatedCallback(AuthenticatedCallback callback) { + m_onAuthenticated = std::move(callback); +} + +void FaceAuthenticator::setStatusCallback(StatusCallback callback) { m_onStatus = std::move(callback); } + +void FaceAuthenticator::start() { + if (m_active) { + return; + } + + m_active = true; + m_abort = false; + m_retries = 0; + m_reclaimAttempted = false; + if (m_sleeping) { + return; + } + startVerify(false); +} + +void FaceAuthenticator::stop() { + m_retryTimer.stop(); + m_active = false; + m_verifying = false; + m_claiming = false; + if (!m_abort) { + release(); + } else { + m_proxy.reset(); + } +} + +bool FaceAuthenticator::createProxy() { + try { + m_proxy = sdbus::createProxy(m_bus.connection(), kGazeBusName, kGazePath); + } catch (const sdbus::Error& e) { + kLog.info("gaze daemon not available: {}", e.what()); + m_proxy.reset(); + return false; + } + + bool hasEnrolledFaces = false; + try { + m_proxy->callMethod("HasEnrolledFaces").onInterface(kGazeInterface).withArguments(m_user).storeResultsTo(hasEnrolledFaces); + } catch (const sdbus::Error& e) { + kLog.info("could not check enrolled faces: {}", e.what()); + m_proxy.reset(); + return false; + } + if (!hasEnrolledFaces) { + kLog.debug("no enrolled faces for user {}", m_user); + m_proxy.reset(); + return false; + } + + bool cameraAvailable = false; + try { + m_proxy->callMethod("IsCameraAvailable").onInterface(kGazeInterface).storeResultsTo(cameraAvailable); + } catch (const sdbus::Error& e) { + kLog.info("could not check camera availability: {}", e.what()); + m_proxy.reset(); + return false; + } + if (!cameraAvailable) { + kLog.debug("no camera available for face authentication"); + m_proxy.reset(); + return false; + } + + m_proxy->uponSignal("FaceStatus").onInterface(kGazeInterface).call([this](const std::string& status) { + handleFaceStatus(status); + }); + + // VerifyStatus carries: result, faces vector, rgb_status, ir_status. + // We only need the result string. + m_proxy->uponSignal("VerifyStatus") + .onInterface(kGazeInterface) + .call([this]( + const std::string& result, + const std::vector>& /*faces*/, + const std::string& /*rgbStatus*/, const std::string& /*irStatus*/ + ) { handleVerifyStatus(result); }); + + return true; +} + +void FaceAuthenticator::claim() { + if (m_proxy == nullptr || m_claiming) { + return; + } + m_claiming = true; + + m_proxy->callMethodAsync("Claim") + .onInterface(kGazeInterface) + .withArguments(m_user) + .uponReplyInvoke([this](std::optional e) { + m_claiming = false; + if (e.has_value()) { + kLog.info("could not claim gaze session: {}", e->what()); + m_verifying = false; + return; + } + kLog.info("claimed gaze session"); + if (m_active && !m_sleeping) { + startVerify(false); + } + }); +} + +void FaceAuthenticator::startVerify(bool isRetry) { + if (!m_active || m_sleeping || m_abort) { + return; + } + m_verifying = true; + + if (m_proxy == nullptr) { + if (!createProxy()) { + m_verifying = false; + return; + } + + emitStatus(i18n::tr("auth.face.ready"), false); + claim(); + return; + } + if (m_claiming) { + return; + } + + m_proxy->callMethodAsync("VerifyStart") + .onInterface(kGazeInterface) + .withArguments(std::string{"any"}) + .uponReplyInvoke([this, isRetry](std::optional e) { + if (e.has_value()) { + kLog.info("could not start face verification: {}", e->what()); + m_verifying = false; + // A claim dropped across suspend/resume makes VerifyStart fail; drop the + // stale proxy and recreate+reclaim once. Destroying the proxy here would + // use-after-free the async reply handler, so defer the reset. + if (!m_reclaimAttempted && m_active && !m_sleeping && !m_abort && isRecoverableClaimError(*e)) { + m_reclaimAttempted = true; + m_retryTimer.start(kRetryDelay, [this]() { + m_proxy.reset(); + startVerify(false); + }); + return; + } + emitStatus({}, false); + return; + } + kLog.debug("face verification started"); + m_reclaimAttempted = false; + if (isRetry) { + m_retries++; + } + emitStatus(i18n::tr("auth.face.ready"), false); + }); +} + +void FaceAuthenticator::stopVerify() { + m_retryTimer.stop(); + m_verifying = false; + if (m_proxy == nullptr) { + return; + } + try { + m_proxy->callMethod("VerifyStop").onInterface(kGazeInterface); + kLog.debug("face verification stopped"); + } catch (const sdbus::Error& e) { + kLog.debug("could not stop face verification: {}", e.what()); + } +} + +void FaceAuthenticator::release() { + if (m_proxy == nullptr) { + return; + } + try { + m_proxy->callMethod("Release").onInterface(kGazeInterface); + kLog.info("released gaze session"); + } catch (const sdbus::Error& e) { + kLog.debug("could not release gaze session: {}", e.what()); + } + m_proxy.reset(); +} + +void FaceAuthenticator::handleFaceStatus(const std::string& status) { + if (!m_active) { + return; + } + const auto msg = captureStatusMessage(status); + if (msg.has_value()) { + emitStatus(i18n::tr(msg->key), msg->isError); + } +} + +void FaceAuthenticator::handleVerifyStatus(const std::string& result) { + if (!m_active) { + return; + } + if (m_sleeping) { + stopVerify(); + return; + } + + kLog.debug("face verify result: {}", result); + + if (result == "verify-match") { + stopVerify(); + if (m_onAuthenticated) { + m_onAuthenticated(); + } + return; + } + + if (result == "verify-no-match") { + stopVerify(); + if (m_retries >= kMaxRetries) { + emitStatus(i18n::tr("auth.face.too-many-attempts"), true); + } else { + emitStatus(i18n::tr("auth.face.no-match"), true); + m_retryTimer.start(kRetryDelay, [this]() { startVerify(true); }); + } + return; + } + + kLog.warn("unknown face verify result: {}", result); +} + +void FaceAuthenticator::emitStatus(const std::string& message, bool isError) { + if (m_onStatus) { + m_onStatus(message, isError); + } +} diff --git a/src/auth/face_authenticator.h b/src/auth/face_authenticator.h new file mode 100644 index 0000000000..3973cf4040 --- /dev/null +++ b/src/auth/face_authenticator.h @@ -0,0 +1,59 @@ +#pragma once + +#include "core/timer_manager.h" + +#include +#include +#include + +class SystemBus; + +namespace sdbus { + class IProxy; +} + +class FaceAuthenticator { +public: + using AuthenticatedCallback = std::function; + using StatusCallback = std::function; + + explicit FaceAuthenticator(SystemBus& bus); + ~FaceAuthenticator(); + + FaceAuthenticator(const FaceAuthenticator&) = delete; + FaceAuthenticator& operator=(const FaceAuthenticator&) = delete; + + void setAuthenticatedCallback(AuthenticatedCallback callback); + void setStatusCallback(StatusCallback callback); + + void start(); + void stop(); + +private: + bool createProxy(); + void claim(); + void startVerify(bool isRetry); + void stopVerify(); + void release(); + void handleFaceStatus(const std::string& status); + void handleVerifyStatus(const std::string& result); + void emitStatus(const std::string& message, bool isError); + + SystemBus& m_bus; + std::unique_ptr m_loginManager; + std::unique_ptr m_proxy; + + AuthenticatedCallback m_onAuthenticated; + StatusCallback m_onStatus; + + Timer m_retryTimer; + std::string m_user; + + bool m_active = false; + bool m_verifying = false; + bool m_claiming = false; + bool m_sleeping = false; + bool m_abort = false; + bool m_reclaimAttempted = false; + int m_retries = 0; +}; diff --git a/src/config/config_types.h b/src/config/config_types.h index 117ca3716c..1ee096e25c 100644 --- a/src/config/config_types.h +++ b/src/config/config_types.h @@ -479,6 +479,7 @@ struct BackdropConfig { struct LockscreenConfig { bool enabled = true; bool fingerprint = true; + bool faceAuth = true; bool allowEmptyPassword = false; bool blurredDesktop = false; float blurIntensity = 0.5f; diff --git a/src/config/schema/config_schema.cpp b/src/config/schema/config_schema.cpp index 94d5c06e6f..f5b9a89fd4 100644 --- a/src/config/schema/config_schema.cpp +++ b/src/config/schema/config_schema.cpp @@ -89,6 +89,7 @@ namespace noctalia::config::schema { static const Schema s = { field(&LockscreenConfig::enabled, "enabled"), field(&LockscreenConfig::fingerprint, "fingerprint"), + field(&LockscreenConfig::faceAuth, "face_auth"), field(&LockscreenConfig::allowEmptyPassword, "allow_empty_password"), field(&LockscreenConfig::blurredDesktop, "blurred_desktop"), field(&LockscreenConfig::blurIntensity, "blur_intensity", kUnitRange), diff --git a/src/dbus/polkit/polkit_agent.cpp b/src/dbus/polkit/polkit_agent.cpp index 96fe6b96d0..e29d978394 100644 --- a/src/dbus/polkit/polkit_agent.cpp +++ b/src/dbus/polkit/polkit_agent.cpp @@ -634,6 +634,14 @@ struct PolkitAgent::Impl { } void handleRequest(const std::string& prompt, bool echoOn) { + if (prompt == "GAZE_CONFIRMATION_REQUEST" && session != nullptr) { + kLog.info("auto-confirming Gaze face authentication"); + polkit_agent_session_response(session, "CONFIRM"); + supplementaryMessage = i18n::tr("auth.face.recognized"); + supplementaryError = false; + emitStateChanged(); + return; + } inputPrompt = prompt.empty() ? i18n::tr("auth.polkit.default-message") : prompt; responseVisible = echoOn; responseRequired = true; diff --git a/src/shell/lockscreen/lock_screen.cpp b/src/shell/lockscreen/lock_screen.cpp index 86d736212c..d1ffbdd86f 100644 --- a/src/shell/lockscreen/lock_screen.cpp +++ b/src/shell/lockscreen/lock_screen.cpp @@ -1,5 +1,6 @@ #include "shell/lockscreen/lock_screen.h" +#include "auth/face_authenticator.h" #include "auth/fingerprint_authenticator.h" #include "capture/screencopy_util.h" #include "compositors/compositor_platform.h" @@ -77,6 +78,17 @@ bool LockScreen::initialize( m_fingerprint->setStatusCallback([this](const std::string& message, bool isError) { handleFingerprintStatus(message, isError); }); + + m_faceAuth = std::make_unique(*m_systemBus); + m_faceAuth->setAuthenticatedCallback([this]() { + m_status = i18n::tr("lockscreen.unlocked"); + m_statusIsError = false; + updatePromptOnSurfaces(); + unlock(); + }); + m_faceAuth->setStatusCallback([this](const std::string& message, bool isError) { + handleFaceAuthStatus(message, isError); + }); } return true; } @@ -162,6 +174,7 @@ void LockScreen::unlock() { m_suspendTimeoutTimer.stop(); invalidatePendingAuthentication(); stopFingerprint(); + stopFaceAuth(); const bool wasLockedInteractive = m_locked; @@ -435,6 +448,7 @@ void LockScreen::handleLocked(void* data, ext_session_lock_v1* /*lock*/) { self->updatePromptOnSurfaces(); self->updateIndicatorsOnSurfaces(); self->startFingerprint(); + self->startFaceAuth(); kLog.info("session is locked"); if (self->m_onSessionLocked) { self->m_onSessionLocked(); @@ -448,6 +462,7 @@ void LockScreen::handleFinished(void* data, ext_session_lock_v1* /*lock*/) { self->m_pendingAfterLocked = {}; self->invalidatePendingAuthentication(); self->stopFingerprint(); + self->stopFaceAuth(); if (self->m_lock != nullptr) { if (self->m_locked) { @@ -807,6 +822,7 @@ void LockScreen::tryAuthenticate() { } stopFingerprint(); + stopFaceAuth(); if (m_wayland != nullptr) { m_wayland->stopKeyRepeat(); } @@ -851,6 +867,7 @@ void LockScreen::handleAuthResult(std::uint64_t generation, PamAuthenticator::Re m_statusIsError = true; updatePromptOnSurfaces(); startFingerprint(); + startFaceAuth(); } void LockScreen::startFingerprint() { @@ -883,6 +900,34 @@ void LockScreen::handleFingerprintStatus(const std::string& message, bool isErro updatePromptOnSurfaces(); } +void LockScreen::startFaceAuth() { + if (m_faceAuth == nullptr) { + return; + } + if (m_configService != nullptr && !m_configService->config().lockscreen.faceAuth) { + return; + } + m_faceAuth->start(); +} + +void LockScreen::stopFaceAuth() { + if (m_faceAuth != nullptr) { + m_faceAuth->stop(); + } +} + +void LockScreen::handleFaceAuthStatus(const std::string& message, bool isError) { + if (!isActive()) { + return; + } + if (!m_password.empty()) { + return; + } + m_status = message.empty() ? std::string{} : message; + m_statusIsError = isError; + updatePromptOnSurfaces(); +} + void LockScreen::clearSensitiveString(std::string& value) { volatile char* ptr = value.empty() ? nullptr : value.data(); for (std::size_t i = 0; i < value.size(); ++i) { diff --git a/src/shell/lockscreen/lock_screen.h b/src/shell/lockscreen/lock_screen.h index 23bc944128..c41d737066 100644 --- a/src/shell/lockscreen/lock_screen.h +++ b/src/shell/lockscreen/lock_screen.h @@ -23,6 +23,7 @@ struct wl_output; class ConfigService; class CompositorPlatform; +class FaceAuthenticator; class FingerprintAuthenticator; class LockSurface; class RenderContext; @@ -108,6 +109,9 @@ class LockScreen { void startFingerprint(); void stopFingerprint(); void handleFingerprintStatus(const std::string& message, bool isError); + void startFaceAuth(); + void stopFaceAuth(); + void handleFaceAuthStatus(const std::string& message, bool isError); static void clearSensitiveString(std::string& value); WaylandConnection* m_wayland = nullptr; @@ -121,6 +125,7 @@ class LockScreen { std::unordered_map m_desktopCaptures; PamAuthenticator m_authenticator; std::unique_ptr m_fingerprint; + std::unique_ptr m_faceAuth; std::string m_user; std::string m_password; std::string m_status; diff --git a/src/shell/settings/settings_registry.cpp b/src/shell/settings/settings_registry.cpp index 8d4605e384..ac9939ebef 100644 --- a/src/shell/settings/settings_registry.cpp +++ b/src/shell/settings/settings_registry.cpp @@ -1518,6 +1518,15 @@ namespace settings { e.visibleWhen = lockscreenOn; entries.push_back(std::move(e)); } + { + auto e = makeEntry( + SettingsSection::Security, "lock-screen", tr("settings.schema.lockscreen.face-auth.label"), + tr("settings.schema.lockscreen.face-auth.description"), {"lockscreen", "face_auth"}, + ToggleSetting{cfg.lockscreen.faceAuth}, "lock screen face unlock gaze biometric" + ); + e.visibleWhen = lockscreenOn; + entries.push_back(std::move(e)); + } { auto e = makeEntry( SettingsSection::Security, "lock-screen", tr("settings.schema.lockscreen.allow-empty-password.label"),