Files
face-unlock/src/daemon/server.cpp
T

720 lines
24 KiB
C++

// 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>
#include <QSocketNotifier>
#include <fcntl.h>
#include <sys/socket.h>
#include <unistd.h>
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;
}
// A face unlock and the lock screen going away this soon after belong
// together.
constexpr qint64 UnlockPairSeconds = 30;
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);
if (m_options.systemdFd >= 0) {
// Accepted here, not handed to QLocalServer: that deletes the socket
// file when it closes, and the file is systemd's. Gone, nothing could
// start the daemon again after it exits idle.
const int fd = m_options.systemdFd;
::fcntl(fd, F_SETFD, FD_CLOEXEC);
::fcntl(fd, F_SETFL, ::fcntl(fd, F_GETFL) | O_NONBLOCK);
auto *notifier = new QSocketNotifier(fd, QSocketNotifier::Read, this);
connect(notifier, &QSocketNotifier::activated, this, &Server::onSystemdConnection);
} else {
QDir().mkpath(QFileInfo(m_options.socketPath).absolutePath());
QLocalServer::removeServer(m_options.socketPath);
m_server.setSocketOptions(QLocalServer::WorldAccessOption);
if (!m_server.listen(m_options.socketPath)) {
*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()) {
addClient(socket);
}
updateIdle();
}
void Server::onSystemdConnection()
{
int fd;
while ((fd = ::accept4(m_options.systemdFd, nullptr, nullptr, SOCK_CLOEXEC | SOCK_NONBLOCK)) >= 0) {
auto *socket = new QLocalSocket;
if (!socket->setSocketDescriptor(fd)) {
::close(fd);
delete socket;
continue;
}
addClient(socket);
}
updateIdle();
}
void Server::addClient(QLocalSocket *socket)
{
auto *client = new Client(socket, this);
if (!client->credentialsKnown()) {
client->deleteLater();
return;
}
connect(client, &Client::request, this, &Server::onRequest);
connect(client, &Client::disconnected, this, &Server::onDisconnected);
m_clients.append(client);
}
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());
// Our unlock goes through logind. The lock screen's password prompt is
// cut off mid-question by it and counts that as a wrong password, so a
// few face unlocks would lock the account (pam_faillock). Taken back here.
const qint64 now = QDateTime::currentSecsSinceEpoch();
if (geteuid() == 0 && state.lastPurpose == u"unlock" && now - state.lastUnlock <= UnlockPairSeconds) {
System::resetFailedLogins(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();
}
}