feat: add plasma-face-unlock
This commit is contained in:
commit
f671acc93b
105 files changed
+13862
No files matched your search
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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())}};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
Reference in new issue
Block a user