feat: add plasma-face-unlock

This commit is contained in:
Felitendo committed 2026-09-22 19:39:04 +02:00
commit f671acc93b
105 files changed
+13862

No files matched your search

+82
View File
@@ -0,0 +1,82 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "agentlink.h"
#include <QFile>
#include <QJsonDocument>
#include <QTimer>
#include <sys/socket.h>
#include <sys/stat.h>
namespace
{
bool ownedBy(const QString &path, uid_t uid, bool socket)
{
struct stat st;
if (::lstat(QFile::encodeName(path).constData(), &st) != 0 || st.st_uid != uid) {
return false;
}
return socket ? S_ISSOCK(st.st_mode) : S_ISDIR(st.st_mode);
}
} // namespace
AgentLink::AgentLink(uid_t uid, QObject *parent)
: QObject(parent)
, m_uid(uid)
{
const QString dir = QStringLiteral("/run/user/%1/plasma-face-unlock").arg(uid);
const QString path = dir + QStringLiteral("/agent.socket");
if (!ownedBy(dir, uid, false) || !ownedBy(path, uid, true)) {
// No agent in that session, or not one of this user's making.
QTimer::singleShot(0, this, &QObject::deleteLater);
return;
}
connect(&m_socket, &QLocalSocket::connected, this, [this] {
ucred cred{};
socklen_t len = sizeof(cred);
if (::getsockopt(int(m_socket.socketDescriptor()), SOL_SOCKET, SO_PEERCRED, &cred, &len) != 0 || cred.uid != m_uid) {
m_socket.abort();
deleteLater();
return;
}
m_trusted = true;
flush();
});
connect(&m_socket, &QLocalSocket::errorOccurred, this, [this] {
deleteLater();
});
m_socket.connectToServer(path);
// However the scan ends, this does not outlive it by much.
QTimer::singleShot(60 * 1000, this, &QObject::deleteLater);
}
void AgentLink::send(const QJsonObject &event)
{
m_queue.append(QJsonDocument(event).toJson(QJsonDocument::Compact) + '\n');
flush();
}
void AgentLink::finish()
{
m_finishing = true;
flush();
}
void AgentLink::flush()
{
if (!m_trusted) {
return;
}
for (const QByteArray &line : std::as_const(m_queue)) {
m_socket.write(line);
}
m_queue.clear();
m_socket.flush();
if (m_finishing) {
m_socket.disconnectFromServer();
deleteLater();
}
}
+39
View File
@@ -0,0 +1,39 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Telling a user's session about a scan it did not start itself (sudo, an
// admin prompt, a test from the menu), so the bubble can show it.
//
// The agent listens on /run/user/UID/plasma-face-unlock/agent.socket. That is
// a place the user controls, so nothing is taken for granted: the socket has
// to belong to the user, and so does the process that answers on it, checked
// by the kernel before a single byte is written. What is written is only
// where a scan is ("started", "success", ...), never anything about the face.
#pragma once
#include <QJsonObject>
#include <QList>
#include <QLocalSocket>
#include <QObject>
#include <sys/types.h>
class AgentLink : public QObject
{
Q_OBJECT
public:
AgentLink(uid_t uid, QObject *parent);
void send(const QJsonObject &event);
// Sends what is still queued, then goes away.
void finish();
private:
void flush();
uid_t m_uid;
QLocalSocket m_socket;
QList<QByteArray> m_queue;
bool m_trusted = false;
bool m_finishing = false;
};
+82
View File
@@ -0,0 +1,82 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "client.h"
#include <QJsonDocument>
#include <sys/socket.h>
namespace
{
// A request is a line of JSON a few hundred bytes long. Anything that keeps
// talking without a newline for this long is not a client.
constexpr qsizetype MaxLine = 64 * 1024;
} // namespace
Client::Client(QLocalSocket *socket, QObject *parent)
: QObject(parent)
, m_socket(socket)
{
m_socket->setParent(this);
ucred cred{};
socklen_t len = sizeof(cred);
if (::getsockopt(int(m_socket->socketDescriptor()), SOL_SOCKET, SO_PEERCRED, &cred, &len) == 0) {
m_uid = cred.uid;
m_pid = cred.pid;
m_known = true;
}
connect(m_socket, &QLocalSocket::readyRead, this, &Client::readLines);
connect(m_socket, &QLocalSocket::disconnected, this, [this] {
if (!m_gone) {
m_gone = true;
Q_EMIT disconnected(this);
}
});
}
Client::~Client() = default;
void Client::readLines()
{
m_buffer += m_socket->readAll();
if (m_buffer.size() > MaxLine && !m_buffer.contains('\n')) {
close();
return;
}
qsizetype nl;
while ((nl = m_buffer.indexOf('\n')) >= 0) {
const QByteArray line = m_buffer.left(nl).trimmed();
m_buffer.remove(0, nl + 1);
if (line.isEmpty()) {
continue;
}
const QJsonDocument doc = QJsonDocument::fromJson(line);
if (!doc.isObject()) {
send({{QStringLiteral("event"), QStringLiteral("result")},
{QStringLiteral("ok"), false},
{QStringLiteral("reason"), QStringLiteral("bad-request")}});
continue;
}
Q_EMIT request(this, doc.object());
}
}
void Client::send(const QJsonObject &message)
{
if (m_gone || m_socket->state() != QLocalSocket::ConnectedState) {
return;
}
m_socket->write(QJsonDocument(message).toJson(QJsonDocument::Compact) + '\n');
m_socket->flush();
}
void Client::close()
{
if (m_socket->state() == QLocalSocket::ConnectedState) {
m_socket->flush();
m_socket->disconnectFromServer();
}
}
+57
View File
@@ -0,0 +1,57 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// One connection to the daemon.
//
// The kernel says who is on the other end (SO_PEERCRED), and that is the only
// thing any decision here is based on. Nothing a client writes about itself is
// taken on trust.
#pragma once
#include <QJsonObject>
#include <QLocalSocket>
#include <QObject>
#include <sys/types.h>
class Client : public QObject
{
Q_OBJECT
public:
Client(QLocalSocket *socket, QObject *parent);
~Client() override;
uid_t uid() const
{
return m_uid;
}
pid_t pid() const
{
return m_pid;
}
bool credentialsKnown() const
{
return m_known;
}
void send(const QJsonObject &message);
void close();
// What this connection is for, once it has said so. A connection makes
// one request; "watch", "verify" and "enroll" keep it open.
QString role;
Q_SIGNALS:
void request(Client *client, const QJsonObject &message);
void disconnected(Client *client);
private:
void readLines();
QLocalSocket *m_socket;
QByteArray m_buffer;
uid_t m_uid = uid_t(-1);
pid_t m_pid = 0;
bool m_known = false;
bool m_gone = false;
};
+270
View File
@@ -0,0 +1,270 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "enrolljob.h"
#include "camera.h"
#include "liveness.h"
#include "vision.h"
#include <QDateTime>
#include <QElapsedTimer>
#include <opencv2/imgcodecs.hpp>
#include <opencv2/imgproc.hpp>
#include <array>
#include <cmath>
namespace
{
// A head turned about 15 degrees moves the nose this far (in eye
// distances), sideways for a turn and up or down for a nod. The ring is drawn
// in these units, so 1.0 is a comfortable turn.
constexpr float TurnUnit = 0.13f;
// Far enough out to count for a direction.
constexpr float CaptureAt = 0.8f;
// Straight enough to count as looking straight ahead.
constexpr float StraightYaw = 0.06f;
constexpr qint64 SampleSpacingMs = 150;
constexpr qint64 CentreSpacingMs = 250;
constexpr qint64 PreviewEveryMs = 66;
constexpr qint64 GiveUpAfterMs = 120 * 1000;
constexpr int PreviewWidth = 480;
float median(QList<float> v)
{
if (v.isEmpty()) {
return 0;
}
std::sort(v.begin(), v.end());
return v.at(v.size() / 2);
}
} // namespace
EnrollJob::EnrollJob(Vision *vision, uid_t uid, const Settings &settings, const QString &name)
: Job(vision, uid)
, m_settings(settings)
, m_name(name)
{
}
QString EnrollJob::sectorName(int sector)
{
static const char *names[] = {"up", "up-right", "right", "down-right", "down", "down-left", "left", "up-left"};
return QString::fromLatin1(names[((sector % 8) + 8) % 8]);
}
void EnrollJob::sendPreview(const cv::Mat &frame, const Face *face)
{
cv::Mat mirrored, small;
cv::flip(frame, mirrored, 1);
const double scale = double(PreviewWidth) / mirrored.cols;
cv::resize(mirrored, small, cv::Size(), scale, scale, cv::INTER_AREA);
std::vector<uchar> jpeg;
cv::imencode(".jpg", small, jpeg, {cv::IMWRITE_JPEG_QUALITY, 72});
QJsonObject msg{
{QStringLiteral("event"), QStringLiteral("frame")},
{QStringLiteral("w"), small.cols},
{QStringLiteral("h"), small.rows},
{QStringLiteral("jpeg"), QString::fromLatin1(QByteArray(reinterpret_cast<const char *>(jpeg.data()), qsizetype(jpeg.size())).toBase64())},
};
if (face) {
// In the mirrored picture, as a share of its size.
const float w = float(frame.cols);
const float h = float(frame.rows);
msg.insert(QStringLiteral("face"),
QJsonObject{
{QStringLiteral("x"), double((w - face->box.x - face->box.width) / w)},
{QStringLiteral("y"), double(face->box.y / h)},
{QStringLiteral("w"), double(face->box.width / w)},
{QStringLiteral("h"), double(face->box.height / h)},
});
}
Q_EMIT event(msg);
}
void EnrollJob::run()
{
Camera camera;
QString error;
if (!camera.open(m_settings.camera, &error)) {
result = {{QStringLiteral("event"), QStringLiteral("result")},
{QStringLiteral("ok"), false},
{QStringLiteral("reason"), QStringLiteral("camera")},
{QStringLiteral("message"), error}};
return;
}
Q_EMIT event({{QStringLiteral("event"), QStringLiteral("started")}, {QStringLiteral("camera"), camera.description()}});
QElapsedTimer clock;
clock.start();
QList<float> centreNoseT;
QList<float> centreEyes;
QList<FaceSample> samples;
int centreCount = 0;
qint64 lastCentreSample = -CentreSpacingMs;
bool centreDone = false;
float restingNoseT = 0.55f;
std::array<int, 8> counts{};
std::array<qint64, 8> lastAt;
lastAt.fill(-SampleSpacingMs);
QString lastHint;
const auto hint = [&](const QString &what) {
if (what != lastHint) {
lastHint = what;
Q_EMIT event({{QStringLiteral("event"), QStringLiteral("hint")}, {QStringLiteral("hint"), what}});
}
};
const auto sectorsDone = [&] {
return int(std::count(counts.begin(), counts.end(), SamplesPerSector));
};
cv::Mat frame;
int readFailures = 0;
while (!m_cancel && clock.elapsed() < GiveUpAfterMs) {
double t = 0;
if (!camera.read(frame, &t)) {
if (++readFailures > 10) {
result = {{QStringLiteral("event"), QStringLiteral("result")},
{QStringLiteral("ok"), false},
{QStringLiteral("reason"), QStringLiteral("camera")},
{QStringLiteral("message"), QStringLiteral("the camera stopped delivering frames")}};
return;
}
continue;
}
readFailures = 0;
const qint64 now = clock.elapsed();
const std::vector<Face> faces = m_vision->detect(frame);
const Face *face = faces.empty() ? nullptr : &faces.front();
if (now - m_lastPreview >= PreviewEveryMs) {
m_lastPreview = now;
sendPreview(frame, face);
}
if (!face) {
Q_EMIT event({{QStringLiteral("event"), QStringLiteral("pose")}, {QStringLiteral("face"), false}});
hint(QStringLiteral("no-face"));
continue;
}
// Somebody else in the picture, close enough to be taken for the
// person setting up.
if (faces.size() > 1 && faces[1].box.area() > 0.5f * face->box.area()) {
hint(QStringLiteral("one-face"));
continue;
}
const FaceQuality quality = assessQuality(frame, *face);
if (!quality.ok()) {
hint(quality.tooSmall ? QStringLiteral("closer")
: quality.tooDark ? QStringLiteral("light")
: quality.tooBright ? QStringLiteral("bright")
: QStringLiteral("still"));
continue;
}
const HeadPose pose = estimatePose(*face);
if (!centreDone) {
const bool straight = std::abs(pose.yaw) <= StraightYaw;
Q_EMIT event({{QStringLiteral("event"), QStringLiteral("pose")},
{QStringLiteral("face"), true},
{QStringLiteral("phase"), QStringLiteral("center")},
{QStringLiteral("x"), double(-pose.yaw / TurnUnit)},
{QStringLiteral("y"), 0.0}});
if (!straight) {
hint(QStringLiteral("straight"));
continue;
}
hint(QStringLiteral("hold"));
centreNoseT.append(pose.noseT);
const EyeSample eyes = measureEyes(frame, *face);
if (eyes.valid) {
centreEyes.append(eyes.openness);
}
if (centreCount < CentreSamples && now - lastCentreSample >= CentreSpacingMs) {
const Embedding e = m_vision->embed(frame, *face);
if (!e.empty()) {
samples.append({e, QStringLiteral("center"), QDateTime::currentSecsSinceEpoch()});
++centreCount;
lastCentreSample = now;
}
}
if (centreCount >= CentreSamples && centreNoseT.size() >= 6) {
restingNoseT = median(centreNoseT);
centreDone = true;
Q_EMIT event({{QStringLiteral("event"), QStringLiteral("captured")}, {QStringLiteral("pose"), QStringLiteral("center")}});
hint(QStringLiteral("circle"));
}
continue;
}
// Screen directions in the mirrored preview: turning to one's own left
// moves the face to the left of the screen, looking up moves it up.
const float x = -pose.yaw / TurnUnit;
const float y = -(pose.noseT - restingNoseT) / TurnUnit;
const float reach = std::hypot(x, y);
double angle = std::atan2(x, y) * 180.0 / M_PI;
if (angle < 0) {
angle += 360;
}
Q_EMIT event({{QStringLiteral("event"), QStringLiteral("pose")},
{QStringLiteral("face"), true},
{QStringLiteral("phase"), QStringLiteral("circle")},
{QStringLiteral("x"), double(x)},
{QStringLiteral("y"), double(y)},
{QStringLiteral("angle"), angle},
{QStringLiteral("reach"), double(reach)}});
if (reach >= CaptureAt) {
const int sector = int(std::lround(angle / 45.0)) % 8;
if (counts[sector] < SamplesPerSector && now - lastAt[sector] >= SampleSpacingMs) {
const Embedding e = m_vision->embed(frame, *face);
if (!e.empty()) {
samples.append({e, sectorName(sector), QDateTime::currentSecsSinceEpoch()});
lastAt[sector] = now;
if (++counts[sector] == SamplesPerSector) {
Q_EMIT event({{QStringLiteral("event"), QStringLiteral("captured")},
{QStringLiteral("pose"), sectorName(sector)},
{QStringLiteral("sector"), sector},
{QStringLiteral("done"), sectorsDone()}});
}
}
}
}
if (sectorsDone() == 8 || (m_finishEarly && sectorsDone() >= SectorsForEarlyFinish)) {
break;
}
}
if (m_cancel) {
result = {{QStringLiteral("event"), QStringLiteral("result")}, {QStringLiteral("ok"), false}, {QStringLiteral("reason"), QStringLiteral("cancelled")}};
return;
}
if (!centreDone || sectorsDone() < SectorsForEarlyFinish) {
result = {{QStringLiteral("event"), QStringLiteral("result")}, {QStringLiteral("ok"), false}, {QStringLiteral("reason"), QStringLiteral("timeout")}};
return;
}
identity.id = FaceStore::newId();
identity.name = m_name;
identity.created = QDateTime::currentSecsSinceEpoch();
identity.noseT = restingNoseT;
identity.eyes = median(centreEyes);
identity.samples = samples;
result = {{QStringLiteral("event"), QStringLiteral("result")},
{QStringLiteral("ok"), true},
{QStringLiteral("reason"), QStringLiteral("ok")},
{QStringLiteral("id"), identity.id},
{QStringLiteral("name"), identity.name},
{QStringLiteral("samples"), int(samples.size())}};
}
+53
View File
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Setting up a face.
//
// Like the phone: look straight at the camera first, then move the head
// around in a circle while the ring fills up. The centre gives the samples
// most unlocks will match against and the level-head measurements the
// attention check needs; the eight directions around it are what still
// matches when somebody glances at the screen from the side.
#pragma once
#include "job.h"
#include "settings.h"
#include "store.h"
class EnrollJob : public Job
{
Q_OBJECT
public:
EnrollJob(Vision *vision, uid_t uid, const Settings &settings, const QString &name);
void run() override;
QString kind() const override
{
return QStringLiteral("enroll");
}
// Stop as soon as enough of the circle is done.
void finishEarly()
{
m_finishEarly = true;
}
// Filled when the setup completed.
Identity identity;
// The eight directions, clockwise from straight up, as seen in the
// mirrored preview.
static QString sectorName(int sector);
static constexpr int SamplesPerSector = 2;
static constexpr int CentreSamples = 3;
static constexpr int SectorsForEarlyFinish = 4;
private:
void sendPreview(const cv::Mat &frame, const struct Face *face);
Settings m_settings;
QString m_name;
std::atomic_bool m_finishEarly = false;
qint64 m_lastPreview = -1000;
};
+53
View File
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Work that holds the camera: a scan or a setup. Only one runs at a time,
// on a thread of its own so the socket stays responsive (a cancel has to get
// through while a frame is being processed).
#pragma once
#include <QJsonObject>
#include <QObject>
#include <atomic>
#include <sys/types.h>
class Vision;
class Job : public QObject
{
Q_OBJECT
public:
Job(Vision *vision, uid_t uid)
: m_vision(vision)
, m_uid(uid)
{
}
virtual void run() = 0;
virtual QString kind() const = 0;
void cancel()
{
m_cancel = true;
}
bool cancelled() const
{
return m_cancel;
}
uid_t uid() const
{
return m_uid;
}
QJsonObject result;
Q_SIGNALS:
// Progress for whoever asked, and for the bubble.
void event(const QJsonObject &event);
protected:
Vision *m_vision;
uid_t m_uid;
std::atomic_bool m_cancel = false;
};
+111
View File
@@ -0,0 +1,111 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// plasma-face-unlockd: the part that runs as root.
//
// It owns the camera and the face data, and it is the only thing that does.
// Everything else (the lock screen agent, sudo, the setup window, the menu)
// asks it over one socket. systemd starts it on the first connection and it
// exits again after a minute with nothing to do, so most of the time it is not
// running at all.
#include "server.h"
#include "buildconfig.h"
#include <QCommandLineParser>
#include <QCoreApplication>
#include <QSocketNotifier>
#include <csignal>
#include <sys/signalfd.h>
#include <sys/stat.h>
#include <unistd.h>
namespace
{
// sd_listen_fds(), without linking libsystemd for one function.
int systemdSocket()
{
const QByteArray pid = qgetenv("LISTEN_PID");
const QByteArray fds = qgetenv("LISTEN_FDS");
if (pid.isEmpty() || fds.isEmpty() || pid.toLongLong() != getpid() || fds.toInt() < 1) {
return -1;
}
qunsetenv("LISTEN_PID");
qunsetenv("LISTEN_FDS");
qunsetenv("LISTEN_FDNAMES");
return 3;
}
// SIGTERM from systemd is the normal way this ends. The camera thread is
// cancelled and joined on the way out rather than being cut off mid-frame.
void quitOnSignals(QCoreApplication &app)
{
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGTERM);
sigaddset(&mask, SIGINT);
sigprocmask(SIG_BLOCK, &mask, nullptr);
const int fd = signalfd(-1, &mask, SFD_CLOEXEC | SFD_NONBLOCK);
if (fd < 0) {
return;
}
auto *notifier = new QSocketNotifier(fd, QSocketNotifier::Read, &app);
QObject::connect(notifier, &QSocketNotifier::activated, &app, [fd] {
signalfd_siginfo info;
while (read(fd, &info, sizeof(info)) > 0) {
}
QCoreApplication::quit();
});
}
} // namespace
int main(int argc, char **argv)
{
// Face data and state are for root's eyes only, whatever creates them.
umask(077);
QCoreApplication app(argc, argv);
app.setApplicationName(QStringLiteral("plasma-face-unlockd"));
app.setApplicationVersion(QStringLiteral(PFU_VERSION));
qSetMessagePattern(QStringLiteral("%{if-warning}warning: %{endif}%{if-critical}error: %{endif}%{message}"));
QCommandLineParser parser;
parser.setApplicationDescription(QStringLiteral("Face unlock daemon for KDE Plasma"));
parser.addHelpOption();
parser.addVersionOption();
const QCommandLineOption socketOpt(QStringLiteral("socket"), QStringLiteral("Listen here instead of the system socket."), QStringLiteral("path"),
QStringLiteral(PFU_SOCKET));
const QCommandLineOption stateOpt(QStringLiteral("state-dir"), QStringLiteral("Where face data is kept."), QStringLiteral("dir"),
QStringLiteral(PFU_STATEDIR));
const QCommandLineOption configOpt(QStringLiteral("config"), QStringLiteral("The settings file."), QStringLiteral("file"), QStringLiteral(PFU_CONFIG));
const QCommandLineOption modelOpt(QStringLiteral("models"), QStringLiteral("Where the networks are."), QStringLiteral("dir"),
QStringLiteral(PFU_MODELDIR));
const QCommandLineOption idleOpt(QStringLiteral("idle-exit"), QStringLiteral("Exit after this many idle seconds (0: never)."), QStringLiteral("seconds"));
parser.addOptions({socketOpt, stateOpt, configOpt, modelOpt, idleOpt});
parser.process(app);
ServerOptions options;
options.socketPath = parser.value(socketOpt);
options.stateDir = parser.value(stateOpt);
options.configPath = parser.value(configOpt);
options.modelDir = parser.value(modelOpt);
options.systemdFd = systemdSocket();
// Started by the socket: go away again when there is nothing to do.
// Started by hand (development): stay.
options.idleSeconds = parser.isSet(idleOpt) ? parser.value(idleOpt).toInt() : (options.systemdFd >= 0 ? 60 : 0);
options.askPolkit = geteuid() == 0;
if (!options.askPolkit) {
qInfo("not running as root: face changes are not checked with polkit (development only)");
}
quitOnSignals(app);
Server server(options);
QString error;
if (!server.start(&error)) {
qCritical("cannot listen: %s", qPrintable(error));
return 1;
}
return app.exec();
}
+115
View File
@@ -0,0 +1,115 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "polkit.h"
#include "system.h"
#include <QDBusArgument>
#include <QDBusConnection>
#include <QDBusMessage>
#include <QDBusMetaType>
#include <QDBusPendingCallWatcher>
#include <QDBusPendingReply>
#include <QLoggingCategory>
namespace
{
// (sa{sv}): the subject, "unix-process" with its pid, start time and uid.
struct Subject {
QString kind;
QVariantMap details;
};
// (bba{ss}): authorized, challenge, details.
struct Result {
bool authorized = false;
bool challenge = false;
QMap<QString, QString> details;
};
} // namespace
Q_DECLARE_METATYPE(Subject)
Q_DECLARE_METATYPE(Result)
namespace
{
QDBusArgument &operator<<(QDBusArgument &arg, const Subject &s)
{
arg.beginStructure();
arg << s.kind << s.details;
arg.endStructure();
return arg;
}
const QDBusArgument &operator>>(const QDBusArgument &arg, Subject &s)
{
arg.beginStructure();
arg >> s.kind >> s.details;
arg.endStructure();
return arg;
}
QDBusArgument &operator<<(QDBusArgument &arg, const Result &r)
{
arg.beginStructure();
arg << r.authorized << r.challenge << r.details;
arg.endStructure();
return arg;
}
const QDBusArgument &operator>>(const QDBusArgument &arg, Result &r)
{
arg.beginStructure();
arg >> r.authorized >> r.challenge >> r.details;
arg.endStructure();
return arg;
}
void registerTypes()
{
static bool done = false;
if (!done) {
qDBusRegisterMetaType<Subject>();
qDBusRegisterMetaType<Result>();
qDBusRegisterMetaType<QMap<QString, QString>>();
done = true;
}
}
constexpr quint32 AllowUserInteraction = 1;
// Long enough to find the dialog and type a password.
constexpr int TimeoutMs = 5 * 60 * 1000;
} // namespace
namespace Polkit
{
void checkAuthorization(pid_t pid, uid_t uid, const char *action, QObject *context, std::function<void(bool)> done)
{
registerTypes();
Subject subject;
subject.kind = QStringLiteral("unix-process");
subject.details.insert(QStringLiteral("pid"), QVariant::fromValue(quint32(pid)));
subject.details.insert(QStringLiteral("start-time"), QVariant::fromValue(quint64(System::processStartTime(pid))));
subject.details.insert(QStringLiteral("uid"), QVariant::fromValue(qint32(uid)));
QDBusMessage msg = QDBusMessage::createMethodCall(QStringLiteral("org.freedesktop.PolicyKit1"),
QStringLiteral("/org/freedesktop/PolicyKit1/Authority"),
QStringLiteral("org.freedesktop.PolicyKit1.Authority"),
QStringLiteral("CheckAuthorization"));
msg << QVariant::fromValue(subject) << QString::fromLatin1(action) << QVariant::fromValue(QMap<QString, QString>())
<< AllowUserInteraction << QString();
const QDBusPendingCall call = QDBusConnection::systemBus().asyncCall(msg, TimeoutMs);
auto *watcher = new QDBusPendingCallWatcher(call, context);
QObject::connect(watcher, &QDBusPendingCallWatcher::finished, context, [watcher, done] {
watcher->deleteLater();
const QDBusPendingReply<Result> reply = *watcher;
if (reply.isError()) {
qWarning("polkit: %s", qPrintable(reply.error().message()));
done(false);
return;
}
done(reply.value().authorized);
});
}
} // namespace Polkit
+25
View File
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Asking polkit whether a process may change face data.
//
// Adding a face is adding a way in, so it asks for the password first, the
// same way a phone asks for its code before it sets up a face. The question
// goes to the polkit agent of the person's own session (on Plasma, the
// familiar password dialog), which is why the daemon never sees the
// password.
#pragma once
#include <QObject>
#include <functional>
#include <sys/types.h>
namespace Polkit
{
inline constexpr char ManageAction[] = "io.github.loonixtools.plasma-face-unlock.manage";
// Calls done(true) when the process may go ahead. Interactive: this can take
// as long as it takes somebody to type a password.
void checkAuthorization(pid_t pid, uid_t uid, const char *action, QObject *context, std::function<void(bool)> done);
} // namespace Polkit
+316
View File
@@ -0,0 +1,316 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "scanjob.h"
#include "camera.h"
#include "liveness.h"
#include "vision.h"
#include <QElapsedTimer>
#include <QJsonArray>
#include <cmath>
namespace
{
// Two frames have to match. One could be a fluke of the light.
constexpr int MatchesNeeded = 2;
// Once somebody has matched, every frame spent on the recognizer is a frame
// the blink check does not see, and a blink is only a few frames long. So
// after a match the recognizer only runs on every fourth frame, which is
// still often enough to notice a different face.
constexpr int RecheckEvery = 4;
// A face that jumps further than this between two frames is treated as a
// different face, and everything learned about the previous one is dropped.
constexpr float JumpLimit = 0.6f;
// How long the deny cues watch before "light" lets anybody in.
constexpr int LightFramesNeeded = 5;
// Heavy: after a match, how long to wait for a sign of life before asking
// for one, and how much longer to wait once asked.
constexpr qint64 AskForLifeAfterMs = 1200;
constexpr qint64 ExtraTimeMs = 2500;
// Attention: a head turned further than this is not looking at the screen.
constexpr float MaxYawDegrees = 25;
constexpr float MaxPitchT = 0.16f;
double median(QList<float> v)
{
if (v.isEmpty()) {
return 0;
}
std::sort(v.begin(), v.end());
return v.at(v.size() / 2);
}
} // namespace
ScanJob::ScanJob(Vision *vision, uid_t uid, const Settings &settings, const QList<Identity> &faces, const QString &purpose, bool verbose)
: Job(vision, uid)
, m_settings(settings)
, m_faces(faces)
, m_purpose(purpose)
, m_verbose(verbose)
{
}
void ScanJob::finish(bool ok, const QString &reason, const QJsonObject &extra)
{
result = extra;
result.insert(QStringLiteral("event"), QStringLiteral("result"));
result.insert(QStringLiteral("ok"), ok);
result.insert(QStringLiteral("reason"), reason);
}
void ScanJob::hint(const QString &what)
{
if (m_hinted.contains(what)) {
return;
}
m_hinted.insert(what);
Q_EMIT event({{QStringLiteral("event"), QStringLiteral("hint")}, {QStringLiteral("hint"), what}});
}
void ScanJob::run()
{
Camera camera;
QString error;
if (!camera.open(m_settings.camera, &error)) {
finish(false, QStringLiteral("camera"), {{QStringLiteral("message"), error}});
return;
}
Q_EMIT event({{QStringLiteral("event"), QStringLiteral("started")}, {QStringLiteral("camera"), camera.description()}});
// What the attention check compares against: this person's own level-head
// nose position and open-eye measurement, from the setup.
QList<float> noseTs, eyes;
for (const Identity &id : std::as_const(m_faces)) {
if (id.enabled) {
noseTs.append(id.noseT);
if (id.eyes > 0) {
eyes.append(id.eyes);
}
}
}
const float restingNoseT = float(median(noseTs));
const float openEyes = float(median(eyes));
const double threshold = m_settings.threshold();
const LivenessMode mode = m_settings.liveness;
QElapsedTimer clock;
clock.start();
qint64 deadline = qint64(m_settings.scanSeconds) * 1000;
bool extended = false;
LivenessAnalyzer live;
int matched = 0;
int mismatched = 0;
int attentionMisses = 0;
int qualityMisses = 0;
int sinceCheck = 0;
bool lastCheckMatched = false;
bool sawFace = false;
int readFailures = 0;
qint64 firstMatchAt = -1;
cv::Point2f lastCentre(-1, -1);
qint64 lastFaceAt = -1;
float bestSeen = -1;
const auto forgetMatch = [&] {
matched = 0;
lastCheckMatched = false;
firstMatchAt = -1;
matchedIdentity = -1;
matchedScore = 0;
matchedEmbedding.clear();
};
cv::Mat frame;
while (!m_cancel) {
const qint64 elapsed = clock.elapsed();
if (elapsed > deadline) {
// Heavy has matched and is only waiting for a sign of life: give
// the person the time to blink that the hint just asked for.
if (mode == LivenessMode::Heavy && matched >= MatchesNeeded && !extended) {
deadline += ExtraTimeMs;
extended = true;
continue;
}
break;
}
double t = 0;
if (!camera.read(frame, &t)) {
if (++readFailures > 10) {
finish(false, QStringLiteral("camera"), {{QStringLiteral("message"), QStringLiteral("the camera stopped delivering frames")}});
return;
}
continue;
}
readFailures = 0;
const std::vector<Face> faces = m_vision->detect(frame);
if (faces.empty()) {
continue;
}
const Face &face = faces.front();
const float iod = face.interocular();
const cv::Point2f centre = (face.eyeMid() + face.mouthMid()) * 0.5f;
// A face that appeared somewhere else, or after a gap, may be a
// different one. Whatever was concluded about the last one goes.
const bool jumped = lastCentre.x >= 0 && float(cv::norm(centre - lastCentre)) > JumpLimit * iod;
const bool gap = lastFaceAt >= 0 && elapsed - lastFaceAt > 400;
if (jumped || gap) {
live.reset();
forgetMatch();
}
lastCentre = centre;
lastFaceAt = elapsed;
if (!sawFace) {
sawFace = true;
Q_EMIT event({{QStringLiteral("event"), QStringLiteral("face")}});
}
const FaceQuality quality = assessQuality(frame, face);
const LivenessFrame lf = measureFrame(frame, face, t);
live.add(lf);
const LivenessReading reading = live.reading();
if (mode != LivenessMode::Off && reading.denied) {
finish(false, QStringLiteral("spoof"), {{QStringLiteral("cue"), reading.deniedBy}});
return;
}
if (!quality.ok()) {
++qualityMisses;
if (quality.tooSmall) {
hint(QStringLiteral("closer"));
} else if (quality.tooDark) {
hint(QStringLiteral("light"));
}
continue;
}
// Whether this face looks at the screen with open eyes. Only asked of
// a face that matches: a stranger is a stranger whichever way they
// look, and should be counted as one.
const auto attentive = [&] {
if (!m_settings.attention) {
return true;
}
const bool facing = std::abs(yawDegrees(lf.pose.yaw)) <= MaxYawDegrees
&& (noseTs.isEmpty() || std::abs(lf.pose.noseT - restingNoseT) <= MaxPitchT);
// Eyes that measure as closed, next to how open they measured at
// setup. Skipped for eyes the measure does not work on.
const bool eyesOpen = !lf.eyes.valid || openEyes < 0.15f || lf.eyes.openness >= 0.45f * openEyes;
if (!facing) {
hint(QStringLiteral("look"));
}
return facing && eyesOpen;
};
float score = -1;
bool checked = false;
if (matched < MatchesNeeded || ++sinceCheck >= RecheckEvery) {
sinceCheck = 0;
checked = true;
const Embedding e = m_vision->embed(frame, face);
const FaceMatch m = FaceStore::bestMatch(m_faces, e);
score = m.score;
bestSeen = std::max(bestSeen, m.score);
if (m.identity >= 0 && m.score >= threshold) {
if (!attentive()) {
++attentionMisses;
continue;
}
++matched;
lastCheckMatched = true;
if (firstMatchAt < 0) {
firstMatchAt = elapsed;
}
if (m.score > matchedScore) {
matchedScore = m.score;
matchedIdentity = m.identity;
matchedEmbedding = e;
}
} else {
++mismatched;
// The face that matched before does not match now. Whatever
// it did to look alive was done by somebody else's face.
if (lastCheckMatched) {
live.reset();
forgetMatch();
}
lastCheckMatched = false;
}
} else if (!attentive()) {
// Between checks: a matched face that looks away or closes its
// eyes (a blink does both for a moment) is not counted as looking.
++attentionMisses;
continue;
}
if (m_verbose) {
QJsonObject info{
{QStringLiteral("event"), QStringLiteral("frame")},
{QStringLiteral("t"), qint64(t)},
{QStringLiteral("yaw"), double(yawDegrees(lf.pose.yaw))},
{QStringLiteral("eyes"), double(lf.eyes.openness)},
{QStringLiteral("glare"), double(reading.glare)},
{QStringLiteral("device"), double(reading.device)},
{QStringLiteral("depth"), double(reading.depth)},
{QStringLiteral("blink"), double(reading.blink)},
{QStringLiteral("matched"), matched},
};
if (checked) {
info.insert(QStringLiteral("score"), double(score));
}
Q_EMIT event(info);
}
if (matched < MatchesNeeded || !lastCheckMatched) {
continue;
}
bool alive = true;
switch (mode) {
case LivenessMode::Off:
break;
case LivenessMode::Light:
alive = live.frameCount() >= LightFramesNeeded;
break;
case LivenessMode::Heavy:
alive = reading.confirmed;
if (!alive && elapsed - firstMatchAt > AskForLifeAfterMs) {
hint(QStringLiteral("blink"));
}
break;
}
if (alive) {
const Identity &id = m_faces.at(matchedIdentity);
finish(true, QStringLiteral("ok"),
{{QStringLiteral("name"), id.name},
{QStringLiteral("id"), id.id},
{QStringLiteral("score"), double(matchedScore)},
{QStringLiteral("liveness"), reading.confirmedBy},
{QStringLiteral("ms"), clock.elapsed()}});
return;
}
}
if (m_cancel) {
finish(false, QStringLiteral("cancelled"));
} else if (matched >= MatchesNeeded) {
finish(false, QStringLiteral("liveness"));
} else if (mismatched >= 3) {
finish(false, QStringLiteral("mismatch"), {{QStringLiteral("score"), double(bestSeen)}});
} else if (attentionMisses > 0) {
finish(false, QStringLiteral("attention"));
} else if (qualityMisses > 0) {
finish(false, QStringLiteral("quality"));
} else {
finish(false, QStringLiteral("no-face"));
}
}
+43
View File
@@ -0,0 +1,43 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// One attempt at recognising somebody.
#pragma once
#include "job.h"
#include "settings.h"
#include "store.h"
class ScanJob : public Job
{
Q_OBJECT
public:
ScanJob(Vision *vision, uid_t uid, const Settings &settings, const QList<Identity> &faces, const QString &purpose, bool verbose);
void run() override;
QString kind() const override
{
return QStringLiteral("verify");
}
QString purpose() const
{
return m_purpose;
}
// Set on success: which face matched and what the camera saw, so the
// daemon can learn from it (see FaceStore::adapt).
int matchedIdentity = -1;
float matchedScore = 0;
Embedding matchedEmbedding;
private:
void finish(bool ok, const QString &reason, const QJsonObject &extra = {});
void hint(const QString &what);
Settings m_settings;
QList<Identity> m_faces;
QString m_purpose;
bool m_verbose;
QSet<QString> m_hinted;
};
+676
View File
@@ -0,0 +1,676 @@
// 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 <QCoreApplication>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QLocalSocket>
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<uid_t> 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<void()> then)
{
if (client->uid() == 0 || !m_options.askPolkit) {
then();
return;
}
client->send({{QStringLiteral("event"), QStringLiteral("authorizing")}});
QPointer<Client> 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<EnrollJob *>(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<Identity> 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<Identity> 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<Identity> 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<ScanJob *>(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<Client> owner = m_jobOwner;
m_jobOwner = nullptr;
QJsonObject res = job->result;
const qint64 now = QDateTime::currentSecsSinceEpoch();
if (auto *scan = qobject_cast<ScanJob *>(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<Identity> 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<EnrollJob *>(job)) {
if (res.value(u"ok").toBool()) {
const FaceStore store(m_options.stateDir);
QList<Identity> 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<Identity> 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();
}
}
+96
View File
@@ -0,0 +1,96 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The socket, and who may ask for what.
#pragma once
#include "settings.h"
#include "vision.h"
#include <QJsonObject>
#include <QList>
#include <QLocalServer>
#include <QObject>
#include <QPointer>
#include <QThread>
#include <QTimer>
#include <sys/types.h>
class AgentLink;
class Client;
class Job;
struct ServerOptions {
QString socketPath;
int systemdFd = -1;
QString stateDir;
QString configPath;
QString modelDir;
// Exit after this long with nothing to do. 0 stays up.
int idleSeconds = 0;
// Running as somebody other than root is only ever development, and only
// touches that person's own test data. polkit is not asked then.
bool askPolkit = true;
};
class Server : public QObject
{
Q_OBJECT
public:
explicit Server(const ServerOptions &options, QObject *parent = nullptr);
~Server() override;
bool start(QString *error);
private:
void onConnection();
void onRequest(Client *client, const QJsonObject &request);
void onDisconnected(Client *client);
void handleStatus(Client *client);
void handleCameras(Client *client);
void handleVerify(Client *client, const QJsonObject &request);
void handleEnroll(Client *client, const QJsonObject &request);
void handleList(Client *client, const QJsonObject &request);
void handleChange(Client *client, const QJsonObject &request);
void handleWatch(Client *client);
void handleUnlocked(Client *client);
// The user a request is about: the caller, or for root whoever it names.
// Returns false (and answers) when the caller may not act for them.
bool targetUser(Client *client, const QJsonObject &request, uid_t *uid);
void authorize(Client *client, std::function<void()> then);
void reply(Client *client, QJsonObject message, bool close = true);
void fail(Client *client, const QString &reason, const QJsonObject &extra = {});
bool ensureVision(QString *error);
Settings settings() const;
void startJob(Job *job, Client *owner);
void onJobEvent(const QJsonObject &event);
void onJobDone();
void broadcast(uid_t uid, const QJsonObject &event);
void updateIdle();
ServerOptions m_options;
QLocalServer m_server;
QList<Client *> m_clients;
Vision m_vision;
bool m_visionLoaded = false;
Job *m_job = nullptr;
QThread *m_jobThread = nullptr;
QPointer<Client> m_jobOwner;
// The session to tell about the running scan, when it is not the lock
// screen's own (see agentlink.h).
QPointer<AgentLink> m_agentLink;
QTimer m_idle;
// Asking polkit whether faces may be changed. While that question is
// open, a scan for an admin prompt is refused: the answer to "may a face
// be added" has to be the password, not a face.
int m_manageChecks = 0;
};
+82
View File
@@ -0,0 +1,82 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "system.h"
#include <QDBusConnection>
#include <QDBusInterface>
#include <QDir>
#include <QFile>
#include <pwd.h>
#include <unistd.h>
#include <vector>
namespace System
{
std::optional<uid_t> uidOf(const QString &user)
{
if (user.isEmpty()) {
return std::nullopt;
}
std::vector<char> buf(16384);
passwd pw{};
passwd *result = nullptr;
if (getpwnam_r(user.toLocal8Bit().constData(), &pw, buf.data(), buf.size(), &result) != 0 || !result) {
return std::nullopt;
}
return result->pw_uid;
}
QString nameOf(uid_t uid)
{
std::vector<char> buf(16384);
passwd pw{};
passwd *result = nullptr;
if (getpwuid_r(uid, &pw, buf.data(), buf.size(), &result) != 0 || !result) {
return QString::number(uid);
}
return QString::fromLocal8Bit(result->pw_name);
}
bool lidClosed()
{
QDBusInterface logind(QStringLiteral("org.freedesktop.login1"),
QStringLiteral("/org/freedesktop/login1"),
QStringLiteral("org.freedesktop.login1.Manager"),
QDBusConnection::systemBus());
if (logind.isValid()) {
const QVariant v = logind.property("LidClosed");
if (v.isValid()) {
return v.toBool();
}
}
const QDir lids(QStringLiteral("/proc/acpi/button/lid"));
for (const QString &lid : lids.entryList(QDir::Dirs | QDir::NoDotAndDotDot)) {
QFile state(lids.filePath(lid + QStringLiteral("/state")));
if (state.open(QIODevice::ReadOnly) && state.readAll().contains("closed")) {
return true;
}
}
return false;
}
quint64 processStartTime(pid_t pid)
{
QFile stat(QStringLiteral("/proc/%1/stat").arg(pid));
if (!stat.open(QIODevice::ReadOnly)) {
return 0;
}
const QByteArray line = stat.readAll();
// The second field is the command name in brackets and can contain
// spaces and brackets of its own. Everything after the last ')' is
// plain numbers; the start time is the 20th of them.
const qsizetype close = line.lastIndexOf(')');
if (close < 0) {
return 0;
}
const QList<QByteArray> fields = line.mid(close + 2).split(' ');
return fields.size() > 19 ? fields.at(19).toULongLong() : 0;
}
} // namespace System
+23
View File
@@ -0,0 +1,23 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Small questions about the machine and its users.
#pragma once
#include <QString>
#include <optional>
#include <sys/types.h>
namespace System
{
std::optional<uid_t> uidOf(const QString &user);
QString nameOf(uid_t uid);
// Whether a laptop lid is shut. Asks logind, and falls back to ACPI.
bool lidClosed();
// When a process started, in clock ticks since boot, as polkit wants it to
// tell a process from a later one that got the same pid.
quint64 processStartTime(pid_t pid);
} // namespace System
+55
View File
@@ -0,0 +1,55 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "userstate.h"
#include <QDir>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSaveFile>
namespace
{
QString fileFor(const QString &stateDir, uid_t uid)
{
return QDir(stateDir).filePath(QStringLiteral("users/%1.state").arg(uid));
}
} // namespace
UserState UserState::load(const QString &stateDir, uid_t uid)
{
UserState s;
QFile file(fileFor(stateDir, uid));
if (!file.open(QIODevice::ReadOnly)) {
return s;
}
const QJsonObject o = QJsonDocument::fromJson(file.readAll()).object();
s.failures = o.value(u"failures").toInt();
s.lockedUntil = qint64(o.value(u"lockedUntil").toDouble());
s.lastUnlock = qint64(o.value(u"lastUnlock").toDouble());
s.lastPurpose = o.value(u"lastPurpose").toString();
return s;
}
bool UserState::save(const QString &stateDir, uid_t uid) const
{
QDir().mkpath(QDir(stateDir).filePath(QStringLiteral("users")));
QSaveFile file(fileFor(stateDir, uid));
if (!file.open(QIODevice::WriteOnly)) {
return false;
}
file.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner);
const QJsonObject o{
{QStringLiteral("failures"), failures},
{QStringLiteral("lockedUntil"), double(lockedUntil)},
{QStringLiteral("lastUnlock"), double(lastUnlock)},
{QStringLiteral("lastPurpose"), lastPurpose},
};
file.write(QJsonDocument(o).toJson(QJsonDocument::Compact));
return file.commit();
}
qint64 UserState::lockoutLeft(qint64 now) const
{
return lockedUntil > now ? lockedUntil - now : 0;
}
+27
View File
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// What the daemon remembers about each user between scans: failures in a row,
// the lockout they lead to, and the last successful unlock.
//
// Kept on disk rather than in memory. The daemon exits when it has been idle
// for a minute, and a lockout that ended with the process would be a lockout
// that waiting a minute gets around.
#pragma once
#include <QString>
#include <sys/types.h>
struct UserState {
int failures = 0;
qint64 lockedUntil = 0;
qint64 lastUnlock = 0;
QString lastPurpose;
static UserState load(const QString &stateDir, uid_t uid);
bool save(const QString &stateDir, uid_t uid) const;
// Seconds left, 0 when not locked out.
qint64 lockoutLeft(qint64 now) const;
};