// SPDX-License-Identifier: GPL-3.0-or-later // // The protocol: one JSON object per line, both ways. A client sends one // request; the daemon answers with events and ends with one whose "event" is // "result". "watch" never ends. // // hello version // status [user] faces, lockout, camera, settings // cameras every camera and which one is in use // verify user purpose [verbose] // scan for that user. Events: started, face, // hint, frame (verbose only), result // enroll [name] set up a face for the caller. Asks polkit // first. Events: authorizing, authorized, // started, frame, pose, hint, captured, result. // While it runs the client may send "finish" // (stop once enough is done) or "cancel". // list [user] the caller's faces // remove id | rename id name | enable id | disable id | clear // change the caller's faces (polkit) // watch every scan for the caller, as it happens, // for the bubble // unlocked the caller's session was unlocked some other // way, which ends a lockout // cancel stop this connection's scan or setup // // Who may do what: // - anybody may ask about themselves and scan for themselves. The answer // to a scan is yes or no, which tells a process nothing it could use. // - root may do all of it for anybody. That is sudo and the polkit helper, // through the PAM module. // - changing faces needs polkit on top, because a face is a way in. #include "server.h" #include "agentlink.h" #include "camera.h" #include "client.h" #include "enrolljob.h" #include "polkit.h" #include "scanjob.h" #include "store.h" #include "system.h" #include "userstate.h" #include "buildconfig.h" #include #include #include #include #include #include #include namespace { QJsonObject result(bool ok, const QString &reason = {}) { QJsonObject o{{QStringLiteral("event"), QStringLiteral("result")}, {QStringLiteral("ok"), ok}}; if (!reason.isEmpty()) { o.insert(QStringLiteral("reason"), reason); } return o; } bool isFailureThatCounts(const QString &reason) { // A face that did not match, a fake, or a match that never showed a sign // of life. An empty chair, a broken camera, or somebody looking away do // not count: none of them is anybody trying to get in. return reason == u"mismatch" || reason == u"spoof" || reason == u"liveness"; } } // namespace Server::Server(const ServerOptions &options, QObject *parent) : QObject(parent) , m_options(options) { m_idle.setSingleShot(true); connect(&m_idle, &QTimer::timeout, this, [] { qInfo("idle, exiting"); QCoreApplication::quit(); }); } Server::~Server() { if (m_job) { m_job->cancel(); } if (m_jobThread) { m_jobThread->wait(); } } bool Server::start(QString *error) { QDir().mkpath(m_options.stateDir); QFile::setPermissions(m_options.stateDir, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner); bool ok; if (m_options.systemdFd >= 0) { ok = m_server.listen(qintptr(m_options.systemdFd)); } else { QDir().mkpath(QFileInfo(m_options.socketPath).absolutePath()); QLocalServer::removeServer(m_options.socketPath); m_server.setSocketOptions(QLocalServer::WorldAccessOption); ok = m_server.listen(m_options.socketPath); } if (!ok) { *error = m_server.errorString(); return false; } connect(&m_server, &QLocalServer::newConnection, this, &Server::onConnection); // Loaded up front: whoever started the daemon is about to ask for a scan. QString visionError; if (!ensureVision(&visionError)) { qWarning("%s", qPrintable(visionError)); } updateIdle(); return true; } bool Server::ensureVision(QString *error) { if (!m_visionLoaded) { m_visionLoaded = m_vision.load(m_options.modelDir, error); } return m_visionLoaded; } Settings Server::settings() const { return Settings::load(m_options.configPath); } void Server::onConnection() { while (QLocalSocket *socket = m_server.nextPendingConnection()) { auto *client = new Client(socket, this); if (!client->credentialsKnown()) { client->deleteLater(); continue; } connect(client, &Client::request, this, &Server::onRequest); connect(client, &Client::disconnected, this, &Server::onDisconnected); m_clients.append(client); } updateIdle(); } void Server::onDisconnected(Client *client) { // Whoever asked for a scan has given up on it (the PAM module timed out, // the password was typed, the setup window was closed). The camera goes // off now rather than when the scan would have ended. if (m_job && m_jobOwner == client) { m_job->cancel(); } m_clients.removeAll(client); client->deleteLater(); updateIdle(); } void Server::reply(Client *client, QJsonObject message, bool close) { client->send(message); if (close) { client->close(); } } void Server::fail(Client *client, const QString &reason, const QJsonObject &extra) { QJsonObject o = extra; o.insert(QStringLiteral("event"), QStringLiteral("result")); o.insert(QStringLiteral("ok"), false); o.insert(QStringLiteral("reason"), reason); reply(client, o); } bool Server::targetUser(Client *client, const QJsonObject &request, uid_t *uid) { const QString name = request.value(u"user").toString(); if (name.isEmpty()) { *uid = client->uid(); return true; } const std::optional target = System::uidOf(name); if (!target) { fail(client, QStringLiteral("unknown-user")); return false; } if (client->uid() != 0 && client->uid() != *target) { fail(client, QStringLiteral("denied")); return false; } *uid = *target; return true; } void Server::authorize(Client *client, std::function then) { if (client->uid() == 0 || !m_options.askPolkit) { then(); return; } client->send({{QStringLiteral("event"), QStringLiteral("authorizing")}}); QPointer guard(client); ++m_manageChecks; Polkit::checkAuthorization(client->pid(), client->uid(), Polkit::ManageAction, this, [this, guard, then](bool ok) { --m_manageChecks; if (!guard) { return; } if (!ok) { fail(guard, QStringLiteral("denied")); return; } guard->send({{QStringLiteral("event"), QStringLiteral("authorized")}}); then(); }); } void Server::onRequest(Client *client, const QJsonObject &request) { const QString cmd = request.value(u"cmd").toString(); // Messages for a scan or setup that is already running on this // connection. if (cmd == u"cancel" || cmd == u"finish") { if (m_job && m_jobOwner == client) { if (cmd == u"cancel") { m_job->cancel(); } else if (auto *enroll = qobject_cast(m_job)) { enroll->finishEarly(); } } return; } if (!client->role.isEmpty()) { // One request per connection. return; } client->role = cmd; if (cmd == u"hello") { reply(client, {{QStringLiteral("event"), QStringLiteral("result")}, {QStringLiteral("ok"), true}, {QStringLiteral("version"), QStringLiteral(PFU_VERSION)}}); } else if (cmd == u"status") { handleStatus(client); } else if (cmd == u"cameras") { handleCameras(client); } else if (cmd == u"verify") { handleVerify(client, request); } else if (cmd == u"enroll") { handleEnroll(client, request); } else if (cmd == u"list") { handleList(client, request); } else if (cmd == u"remove" || cmd == u"rename" || cmd == u"enable" || cmd == u"disable" || cmd == u"clear") { handleChange(client, request); } else if (cmd == u"watch") { handleWatch(client); } else if (cmd == u"unlocked") { handleUnlocked(client); } else { fail(client, QStringLiteral("bad-request")); } } // --------------------------------------------------------------------------- // Questions // --------------------------------------------------------------------------- void Server::handleStatus(Client *client) { const uid_t uid = client->uid(); const Settings s = settings(); const FaceStore store(m_options.stateDir); const QList faces = store.load(uid); const UserState state = UserState::load(m_options.stateDir, uid); const qint64 now = QDateTime::currentSecsSinceEpoch(); int enabled = 0; for (const Identity &id : faces) { enabled += id.enabled; } QString path = s.camera; if (path.isEmpty() || path == u"auto") { path = Camera::autoPath(); } QString cameraName; bool present = false; if (path.startsWith(u"file:") || path.startsWith(u"images:")) { cameraName = path; present = true; } else { for (const CameraInfo &c : Camera::list()) { if (c.path == path) { cameraName = c.name; present = true; } } } QString modelError; reply(client, {{QStringLiteral("event"), QStringLiteral("result")}, {QStringLiteral("ok"), true}, {QStringLiteral("version"), QStringLiteral(PFU_VERSION)}, {QStringLiteral("user"), System::nameOf(uid)}, {QStringLiteral("faces"), enabled}, {QStringLiteral("identities"), int(faces.size())}, {QStringLiteral("lockout"), state.lockoutLeft(now)}, {QStringLiteral("lastUnlock"), state.lastUnlock}, {QStringLiteral("lastPurpose"), state.lastPurpose}, {QStringLiteral("camera"), s.camera}, {QStringLiteral("cameraPath"), path}, {QStringLiteral("cameraName"), cameraName}, {QStringLiteral("cameraPresent"), present}, {QStringLiteral("models"), ensureVision(&modelError)}, {QStringLiteral("liveness"), Settings::livenessName(s.liveness)}, {QStringLiteral("strictness"), Settings::strictnessName(s.strictness)}, {QStringLiteral("attention"), s.attention}, {QStringLiteral("scanSeconds"), s.scanSeconds}, {QStringLiteral("busy"), m_job != nullptr}}); } void Server::handleCameras(Client *client) { QJsonArray list; for (const CameraInfo &c : Camera::list()) { list.append(QJsonObject{{QStringLiteral("path"), c.path}, {QStringLiteral("name"), c.name}, {QStringLiteral("infrared"), c.infrared}}); } QJsonObject o = result(true); o.insert(QStringLiteral("cameras"), list); o.insert(QStringLiteral("selected"), settings().camera); o.insert(QStringLiteral("auto"), Camera::autoPath()); reply(client, o); } void Server::handleList(Client *client, const QJsonObject &request) { uid_t uid; if (!targetUser(client, request, &uid)) { return; } const QList faces = FaceStore(m_options.stateDir).load(uid); QJsonArray list; for (const Identity &id : faces) { list.append(QJsonObject{ {QStringLiteral("id"), id.id}, {QStringLiteral("name"), id.name}, {QStringLiteral("created"), double(id.created)}, {QStringLiteral("enabled"), id.enabled}, {QStringLiteral("samples"), int(id.samples.size()) - id.adaptiveCount()}, {QStringLiteral("adaptive"), id.adaptiveCount()}, }); } QJsonObject o = result(true); o.insert(QStringLiteral("faces"), list); reply(client, o); } void Server::handleWatch(Client *client) { reply(client, {{QStringLiteral("event"), QStringLiteral("watching")}}, false); } void Server::handleUnlocked(Client *client) { UserState state = UserState::load(m_options.stateDir, client->uid()); if (state.failures || state.lockedUntil) { state.failures = 0; state.lockedUntil = 0; state.save(m_options.stateDir, client->uid()); } reply(client, result(true)); } // --------------------------------------------------------------------------- // Scanning // --------------------------------------------------------------------------- void Server::handleVerify(Client *client, const QJsonObject &request) { uid_t uid; if (!targetUser(client, request, &uid)) { return; } if (m_job) { fail(client, QStringLiteral("busy")); return; } const Settings s = settings(); const qint64 now = QDateTime::currentSecsSinceEpoch(); const UserState state = UserState::load(m_options.stateDir, uid); if (const qint64 left = state.lockoutLeft(now)) { fail(client, QStringLiteral("lockout"), {{QStringLiteral("seconds"), left}}); return; } if (s.skipLidClosed && System::lidClosed()) { fail(client, QStringLiteral("lid-closed")); return; } QList faces = FaceStore(m_options.stateDir).load(uid); faces.erase(std::remove_if(faces.begin(), faces.end(), [](const Identity &id) { return !id.enabled; }), faces.end()); if (faces.isEmpty()) { fail(client, QStringLiteral("not-enrolled")); return; } QString error; if (!ensureVision(&error)) { fail(client, QStringLiteral("models"), {{QStringLiteral("message"), error}}); return; } QString purpose = request.value(u"purpose").toString(); static const QStringList purposes = {QStringLiteral("unlock"), QStringLiteral("sudo"), QStringLiteral("polkit"), QStringLiteral("test")}; if (!purposes.contains(purpose)) { purpose = QStringLiteral("other"); } // The password dialog that is open right now may be the one asking // whether faces may be changed. See m_manageChecks. if (m_manageChecks > 0 && purpose != u"unlock" && purpose != u"test") { fail(client, QStringLiteral("busy")); return; } auto *job = new ScanJob(&m_vision, uid, s, faces, purpose, request.value(u"verbose").toBool()); // The lock screen's agent draws its own scans. Everybody else's it has // to be told about. if (purpose != u"unlock") { m_agentLink = new AgentLink(uid, this); } broadcast(uid, {{QStringLiteral("event"), QStringLiteral("scan")}, {QStringLiteral("state"), QStringLiteral("start")}, {QStringLiteral("purpose"), purpose}}); startJob(job, client); } void Server::handleEnroll(Client *client, const QJsonObject &request) { uid_t uid; if (!targetUser(client, request, &uid)) { return; } if (m_job) { fail(client, QStringLiteral("busy")); return; } QString name = request.value(u"name").toString().trimmed().left(64); authorize(client, [this, client, uid, name]() mutable { if (m_job) { fail(client, QStringLiteral("busy")); return; } QString error; if (!ensureVision(&error)) { fail(client, QStringLiteral("models"), {{QStringLiteral("message"), error}}); return; } if (name.isEmpty()) { name = System::nameOf(uid); } startJob(new EnrollJob(&m_vision, uid, settings(), name), client); }); } void Server::startJob(Job *job, Client *owner) { m_job = job; m_jobOwner = owner; connect(job, &Job::event, this, &Server::onJobEvent, Qt::QueuedConnection); m_jobThread = QThread::create([job] { job->run(); }); connect(m_jobThread, &QThread::finished, this, &Server::onJobDone, Qt::QueuedConnection); m_jobThread->start(); updateIdle(); } void Server::onJobEvent(const QJsonObject &event) { if (!m_job) { return; } if (m_jobOwner) { m_jobOwner->send(event); } // The bubble hears about scans, not about what the setup window sees. if (auto *scan = qobject_cast(m_job)) { const QString what = event.value(u"event").toString(); if (what == u"face" || what == u"hint") { QJsonObject e{{QStringLiteral("event"), QStringLiteral("scan")}, {QStringLiteral("state"), what}, {QStringLiteral("purpose"), scan->purpose()}}; if (what == u"hint") { e.insert(QStringLiteral("hint"), event.value(u"hint")); } broadcast(scan->uid(), e); } } } void Server::onJobDone() { Job *job = m_job; m_job = nullptr; m_jobThread->deleteLater(); m_jobThread = nullptr; QPointer owner = m_jobOwner; m_jobOwner = nullptr; QJsonObject res = job->result; const qint64 now = QDateTime::currentSecsSinceEpoch(); if (auto *scan = qobject_cast(job)) { const Settings s = settings(); UserState state = UserState::load(m_options.stateDir, scan->uid()); const QString reason = res.value(u"reason").toString(); if (res.value(u"ok").toBool()) { state.failures = 0; state.lockedUntil = 0; state.lastUnlock = now; state.lastPurpose = scan->purpose(); // Learn from a confident match, the way Face ID keeps up with a // beard growing in. Only well clear of the threshold, so the // samples cannot creep towards somebody else one borderline // unlock at a time. if (s.adapt && scan->matchedScore >= s.threshold() + 0.08 && !scan->matchedEmbedding.empty()) { const FaceStore store(m_options.stateDir); QList faces = store.load(scan->uid()); const QString id = res.value(u"id").toString(); for (Identity &identity : faces) { if (identity.id == id && FaceStore::adapt(identity, scan->matchedEmbedding, now)) { QString error; if (!store.save(scan->uid(), faces, &error)) { qWarning("could not save what was learned: %s", qPrintable(error)); } break; } } } } else if (isFailureThatCounts(reason)) { if (++state.failures >= s.maxFailures) { state.failures = 0; state.lockedUntil = now + qint64(s.lockoutMinutes) * 60; res.insert(QStringLiteral("lockout"), state.lockoutLeft(now)); } } state.save(m_options.stateDir, scan->uid()); QJsonObject e{{QStringLiteral("event"), QStringLiteral("scan")}, {QStringLiteral("state"), res.value(u"ok").toBool() ? QStringLiteral("success") : QStringLiteral("failure")}, {QStringLiteral("reason"), reason}, {QStringLiteral("purpose"), scan->purpose()}}; if (res.contains(u"lockout")) { e.insert(QStringLiteral("lockout"), res.value(u"lockout")); } broadcast(scan->uid(), e); qInfo("scan for %s (%s): %s%s", qPrintable(System::nameOf(scan->uid())), qPrintable(scan->purpose()), res.value(u"ok").toBool() ? "recognised" : "not recognised, ", res.value(u"ok").toBool() ? "" : qPrintable(reason)); } else if (auto *enroll = qobject_cast(job)) { if (res.value(u"ok").toBool()) { const FaceStore store(m_options.stateDir); QList faces = store.load(enroll->uid()); faces.append(enroll->identity); QString error; if (!store.save(enroll->uid(), faces, &error)) { res = result(false, QStringLiteral("store")); res.insert(QStringLiteral("message"), error); } else { qInfo("new face \"%s\" for %s", qPrintable(enroll->identity.name), qPrintable(System::nameOf(enroll->uid()))); } } } if (owner) { reply(owner, res); } job->deleteLater(); updateIdle(); } void Server::broadcast(uid_t uid, const QJsonObject &event) { if (m_agentLink) { m_agentLink->send(event); const QString state = event.value(u"state").toString(); if (state == u"success" || state == u"failure") { m_agentLink->finish(); m_agentLink = nullptr; } } for (Client *c : std::as_const(m_clients)) { if (c->role == u"watch" && c->uid() == uid) { c->send(event); } } } // --------------------------------------------------------------------------- // Changing faces // --------------------------------------------------------------------------- void Server::handleChange(Client *client, const QJsonObject &request) { uid_t uid; if (!targetUser(client, request, &uid)) { return; } const QString cmd = request.value(u"cmd").toString(); const QString id = request.value(u"id").toString(); const QString name = request.value(u"name").toString().trimmed().left(64); authorize(client, [this, client, uid, cmd, id, name] { const FaceStore store(m_options.stateDir); QList faces = store.load(uid); bool found = cmd == u"clear"; if (cmd == u"clear") { faces.clear(); } for (qsizetype i = 0; i < faces.size(); ++i) { if (faces[i].id != id) { continue; } found = true; if (cmd == u"remove") { faces.removeAt(i); } else if (cmd == u"rename" && !name.isEmpty()) { faces[i].name = name; } else if (cmd == u"enable") { faces[i].enabled = true; } else if (cmd == u"disable") { faces[i].enabled = false; } break; } if (!found) { fail(client, QStringLiteral("unknown-face")); return; } QString error; if (!store.save(uid, faces, &error)) { fail(client, QStringLiteral("store"), {{QStringLiteral("message"), error}}); return; } reply(client, result(true)); }); } // --------------------------------------------------------------------------- void Server::updateIdle() { if (m_options.idleSeconds > 0 && m_clients.isEmpty() && !m_job) { m_idle.start(m_options.idleSeconds * 1000); } else { m_idle.stop(); } }