feat: add plasma-face-unlock

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

No files matched your search

+52
View File
@@ -0,0 +1,52 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Runs a PAM stack from a directory of its own, so the module can be tried
// against a development daemon without root and without touching /etc/pam.d:
//
// pam_harness CONFDIR SERVICE USER
//
// Prints every message the stack sends, answers nothing, and exits 0 when
// the stack let the user in.
#include <security/pam_appl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static int conversation(int n, const struct pam_message **msg, struct pam_response **resp, void *data)
{
(void)data;
*resp = calloc((size_t)n, sizeof(struct pam_response));
for (int i = 0; i < n; ++i) {
const char *kind = msg[i]->msg_style == PAM_TEXT_INFO ? "info"
: msg[i]->msg_style == PAM_ERROR_MSG ? "error"
: "prompt";
printf("%s: %s\n", kind, msg[i]->msg);
fflush(stdout);
if (msg[i]->msg_style == PAM_PROMPT_ECHO_OFF || msg[i]->msg_style == PAM_PROMPT_ECHO_ON) {
// Nobody is typing a password here.
return PAM_CONV_ERR;
}
}
return PAM_SUCCESS;
}
int main(int argc, char **argv)
{
if (argc != 4) {
fprintf(stderr, "usage: %s CONFDIR SERVICE USER\n", argv[0]);
return 2;
}
const struct pam_conv conv = {conversation, NULL};
pam_handle_t *pamh = NULL;
int rc = pam_start_confdir(argv[2], argv[3], &conv, argv[1], &pamh);
if (rc != PAM_SUCCESS) {
fprintf(stderr, "pam_start: %s\n", pam_strerror(pamh, rc));
return 2;
}
rc = pam_authenticate(pamh, 0);
printf("result: %s\n", pam_strerror(pamh, rc));
pam_end(pamh, rc);
return rc == PAM_SUCCESS ? 0 : 1;
}
+136
View File
@@ -0,0 +1,136 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Recognition on real pictures, for trying things out without a camera:
//
// test_images MODELDIR REFERENCE.jpg OTHER.jpg...
//
// Takes the face in REFERENCE as the enrolled one and prints how every other
// picture compares, with the pose and quality the daemon would see.
//
// test_images --enroll STATEDIR UID NAME MODELDIR PICTURE...
//
// Writes the faces in the pictures straight into a face store, as if they had
// been set up, so a development daemon has somebody to recognise.
#include "liveness.h"
#include "store.h"
#include "vision.h"
#include <QDateTime>
#include <QFile>
#include <QString>
#include <opencv2/imgcodecs.hpp>
#include <opencv2/imgproc.hpp>
#include <cstdio>
namespace
{
cv::Mat load(const char *path)
{
cv::Mat img = cv::imread(path, cv::IMREAD_COLOR);
if (!img.empty()) {
const double s = 640.0 / std::max(img.cols, img.rows);
if (s < 1) {
cv::resize(img, img, {}, s, s, cv::INTER_AREA);
}
}
return img;
}
} // namespace
int enroll(int argc, char **argv)
{
if (argc < 7) {
std::fprintf(stderr, "usage: %s --enroll STATEDIR UID NAME MODELDIR PICTURE...\n", argv[0]);
return 2;
}
Vision vision;
QString error;
if (!vision.load(QString::fromLocal8Bit(argv[5]), &error)) {
std::fprintf(stderr, "%s\n", qPrintable(error));
return 1;
}
Identity id;
id.id = FaceStore::newId();
id.name = QString::fromLocal8Bit(argv[4]);
id.created = QDateTime::currentSecsSinceEpoch();
QList<float> noseT, eyes;
for (int i = 6; i < argc; ++i) {
const cv::Mat img = load(argv[i]);
const std::vector<Face> faces = vision.detect(img);
if (faces.empty()) {
std::fprintf(stderr, "no face in %s\n", argv[i]);
continue;
}
id.samples.append({vision.embed(img, faces.front()), QStringLiteral("center"), id.created});
noseT.append(estimatePose(faces.front()).noseT);
const EyeSample e = measureEyes(img, faces.front());
if (e.valid) {
eyes.append(e.openness);
}
}
if (id.samples.isEmpty()) {
return 1;
}
std::sort(noseT.begin(), noseT.end());
std::sort(eyes.begin(), eyes.end());
id.noseT = noseT.at(noseT.size() / 2);
id.eyes = eyes.isEmpty() ? 0.f : eyes.at(eyes.size() / 2);
const FaceStore store(QString::fromLocal8Bit(argv[2]));
const uint uid = QString::fromLocal8Bit(argv[3]).toUInt();
QList<Identity> all = store.load(uid);
all.append(id);
if (!store.save(uid, all, &error)) {
std::fprintf(stderr, "%s\n", qPrintable(error));
return 1;
}
std::printf("enrolled %s with %d samples\n", qPrintable(id.name), int(id.samples.size()));
return 0;
}
int main(int argc, char **argv)
{
if (argc > 1 && QString::fromLocal8Bit(argv[1]) == u"--enroll") {
return enroll(argc, argv);
}
if (argc < 4) {
std::fprintf(stderr, "usage: %s MODELDIR REFERENCE OTHER...\n", argv[0]);
return 2;
}
Vision vision;
QString error;
if (!vision.load(QString::fromLocal8Bit(argv[1]), &error)) {
std::fprintf(stderr, "%s\n", qPrintable(error));
return 1;
}
const cv::Mat ref = load(argv[2]);
const std::vector<Face> refFaces = vision.detect(ref);
if (refFaces.empty()) {
std::fprintf(stderr, "no face in %s\n", argv[2]);
return 1;
}
const Embedding reference = vision.embed(ref, refFaces.front());
for (int i = 3; i < argc; ++i) {
const cv::Mat img = load(argv[i]);
const std::vector<Face> faces = vision.detect(img);
if (faces.empty()) {
std::printf("%-50s no face\n", argv[i]);
continue;
}
const Face &f = faces.front();
const HeadPose pose = estimatePose(f);
const FaceQuality q = assessQuality(img, f);
const EyeSample eyes = measureEyes(img, f);
const GlareSample glare = measureGlare(img, f);
std::printf("%-50s similarity %.3f yaw %5.1f noseT %.2f iod %3.0f light %3.0f sharp %5.0f eyes %.2f/%.2f glare %.3f/%.2f device %d\n",
argv[i], Vision::similarity(reference, vision.embed(img, f)), yawDegrees(pose.yaw), pose.noseT, f.interocular(),
q.brightness, q.sharpness, eyes.left, eyes.right, glare.fraction, glare.cluster, int(detectDevice(img, f)));
}
return 0;
}
+277
View File
@@ -0,0 +1,277 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The liveness cues against synthetic faces.
//
// There is no camera in a test, so the five landmarks are made the way a
// camera would make them: points of a head in millimetres, turned, projected
// through a pinhole the size of a laptop webcam, and jittered by the noise
// YuNet adds. A real head keeps its nose in front of its face. A photograph
// is the same five points printed flat on a card, and the card is turned and
// tilted instead of the head.
//
// What has to hold, over many random runs:
// - a head that turns about 15 degrees each way confirms,
// - a card turned twice as far never confirms, at any noise level a real
// camera produces,
// - a still head abstains (it is not a fake, there is just no evidence),
// - a blink confirms, and the things that look like one (the whole picture
// blurring as it moves, the light changing, one eye) do not.
#include "liveness.h"
#include <cmath>
#include <cstdio>
#include <functional>
#include <random>
namespace
{
constexpr double Focal = 600; // pixels, a 640x480 webcam with ~56 degree view
constexpr double Cx = 320, Cy = 240;
constexpr double Distance = 550; // millimetres from the camera
constexpr double Fps = 20;
struct P3 {
double x, y, z;
};
// Person-right eye first, the way YuNet reports it. x to the image's right,
// y down, z towards the camera. Proportions of an average adult face: 63 mm
// between the eyes, the nose tip 28 mm in front of the eyes, the mouth corners
// a little in front of them too.
const std::array<P3, 5> Head = {{
{-31.5, 0, 0},
{31.5, 0, 0},
{0, 42, 28},
{-25, 72, 8},
{25, 72, 8},
}};
P3 rotate(P3 p, double yaw, double pitch, double roll)
{
// yaw about y (positive turns the nose to the image's right, which is the
// person's left), pitch about x, roll about z.
P3 a{p.x * std::cos(yaw) + p.z * std::sin(yaw), p.y, -p.x * std::sin(yaw) + p.z * std::cos(yaw)};
P3 b{a.x, a.y * std::cos(pitch) - a.z * std::sin(pitch), a.y * std::sin(pitch) + a.z * std::cos(pitch)};
return {b.x * std::cos(roll) - b.y * std::sin(roll), b.x * std::sin(roll) + b.y * std::cos(roll), b.z};
}
cv::Point2f project(P3 p, double offsetX, double offsetY, double distance)
{
const double depth = distance - p.z;
return {float(Cx + Focal * (p.x + offsetX) / depth), float(Cy + Focal * (p.y + offsetY) / depth)};
}
LivenessFrame frameFrom(const std::array<cv::Point2f, 5> &pts, double t)
{
Face face;
face.points = pts;
LivenessFrame f;
f.t = t;
f.points = pts;
f.interocular = face.interocular();
f.pose = estimatePose(face);
return f;
}
std::array<cv::Point2f, 5> noisy(std::array<cv::Point2f, 5> pts, double sigma, std::mt19937 &rng)
{
std::normal_distribution<float> n(0.f, float(sigma));
for (auto &p : pts) {
p.x += n(rng);
p.y += n(rng);
}
return pts;
}
// A real head over one scan: it turns from side to side by up to `amplitude`
// degrees, nods a little, drifts a little.
LivenessReading realHead(double amplitudeDeg, double sigma, std::mt19937 &rng, double seconds = 3.0)
{
std::uniform_real_distribution<double> phase(0, 2 * M_PI);
const double ph = phase(rng);
LivenessAnalyzer an;
for (int i = 0; i < int(seconds * Fps); ++i) {
const double t = i / Fps;
const double yaw = amplitudeDeg * M_PI / 180 * std::sin(2 * M_PI * 0.4 * t + ph);
const double pitch = 3 * M_PI / 180 * std::sin(2 * M_PI * 0.3 * t);
const double roll = 2 * M_PI / 180 * std::sin(2 * M_PI * 0.2 * t + 1);
std::array<cv::Point2f, 5> pts;
for (int k = 0; k < 5; ++k) {
pts[k] = project(rotate(Head[k], yaw, pitch, roll), 8 * std::sin(t), 4 * std::cos(t), Distance);
}
an.add(frameFrom(noisy(pts, sigma, rng), t * 1000));
}
return an.reading();
}
// Heads are not all the same shape. This one draws a new face for every run:
// flatter and deeper noses, fuller lips, wider or narrower eyes, and turns it
// by a random amount between 12 and 22 degrees at a random noise level.
LivenessReading variedHead(std::mt19937 &rng)
{
std::uniform_real_distribution<double> u(0, 1);
const double eye = 29 + 5 * u(rng), noseZ = 18 + 17 * u(rng), noseY = 38 + 10 * u(rng);
const double mouthZ = 15 * u(rng), mouthY = 65 + 15 * u(rng), mouthX = 22 + 6 * u(rng);
const std::array<P3, 5> head = {{{-eye, 0, 0}, {eye, 0, 0}, {0, noseY, noseZ}, {-mouthX, mouthY, mouthZ}, {mouthX, mouthY, mouthZ}}};
const double amplitude = 12 + 10 * u(rng), ph = 2 * M_PI * u(rng), sigma = 0.5 + 2.0 * u(rng);
LivenessAnalyzer an;
for (int i = 0; i < int(3.0 * Fps); ++i) {
const double t = i / Fps;
const double yaw = amplitude * M_PI / 180 * std::sin(2 * M_PI * 0.4 * t + ph);
const double pitch = 4 * M_PI / 180 * std::sin(2 * M_PI * 0.3 * t);
std::array<cv::Point2f, 5> pts;
for (int k = 0; k < 5; ++k) {
pts[k] = project(rotate(head[k], yaw, pitch, 0), 0, 0, Distance);
}
an.add(frameFrom(noisy(pts, sigma, rng), t * 1000));
}
return an.reading();
}
// A photograph of the same head, taken from straight ahead (or turned by
// `bakedYawDeg`), printed flat and then held up and turned by up to
// `amplitudeDeg`. `bend` curls the card around a vertical axis, in
// millimetres of sag at the edges, which puts some depth back into it.
LivenessReading photo(double amplitudeDeg, double sigma, std::mt19937 &rng, double bakedYawDeg = 0, double bend = 0,
double seconds = 3.0)
{
std::array<P3, 5> card;
for (int k = 0; k < 5; ++k) {
const P3 r = rotate(Head[k], bakedYawDeg * M_PI / 180, 0, 0);
// What the photographer's camera saw, scaled back to life size.
const double depth = Distance - r.z;
const double s = Distance / depth;
card[k] = {r.x * s, r.y * s, 0};
card[k].z = -bend * (card[k].x / 45.0) * (card[k].x / 45.0);
}
std::uniform_real_distribution<double> phase(0, 2 * M_PI);
const double ph = phase(rng);
LivenessAnalyzer an;
for (int i = 0; i < int(seconds * Fps); ++i) {
const double t = i / Fps;
const double yaw = amplitudeDeg * M_PI / 180 * std::sin(2 * M_PI * 0.4 * t + ph);
const double pitch = 0.4 * amplitudeDeg * M_PI / 180 * std::sin(2 * M_PI * 0.3 * t);
const double roll = 4 * M_PI / 180 * std::sin(2 * M_PI * 0.2 * t);
std::array<cv::Point2f, 5> pts;
for (int k = 0; k < 5; ++k) {
pts[k] = project(rotate(card[k], yaw, pitch, roll), 10 * std::sin(t), 5 * std::cos(t), Distance - 100);
}
an.add(frameFrom(noisy(pts, sigma, rng), t * 1000));
}
return an.reading();
}
// ---------------------------------------------------------------------------
// Blinks, as a series of eye measurements
// ---------------------------------------------------------------------------
struct EyeScript {
std::function<float(int)> left;
std::function<float(int)> right;
std::function<float(int)> skin = [](int) { return 150.f; };
std::function<cv::Point2f(int)> shift = [](int) { return cv::Point2f(0, 0); };
};
bool blinkRun(const EyeScript &script, std::mt19937 &rng, int frames = 60)
{
std::normal_distribution<float> n(0.f, 0.03f);
LivenessAnalyzer an;
for (int i = 0; i < frames; ++i) {
std::array<cv::Point2f, 5> pts;
for (int k = 0; k < 5; ++k) {
pts[k] = project(Head[k], 0, 0, Distance) + script.shift(i);
}
LivenessFrame f = frameFrom(pts, i * 1000.0 / 30.0);
f.eyes.valid = true;
f.eyes.left = std::max(0.f, script.left(i) + n(rng));
f.eyes.right = std::max(0.f, script.right(i) + n(rng));
f.eyes.openness = (f.eyes.left + f.eyes.right) / 2;
f.eyes.skin = script.skin(i);
an.add(f);
}
return an.reading().confirmedBy == u"blink";
}
int failures = 0;
// Printed, not judged: what a known limit looks like, so a change that moves
// it shows up in the output.
void report(const char *what, int hits, int runs)
{
std::printf("info %-58s %5.1f%%\n", what, 100.0 * hits / runs);
}
void expectRate(const char *what, int hits, int runs, double min, double max)
{
const double rate = double(hits) / runs;
const bool ok = rate >= min && rate <= max;
std::printf("%s %-58s %5.1f%% (want %.0f%% to %.0f%%)\n", ok ? "ok " : "FAIL", what, rate * 100, min * 100, max * 100);
if (!ok) {
++failures;
}
}
} // namespace
int main()
{
std::mt19937 rng(20260922);
constexpr int Runs = 300;
auto depthRate = [&](auto &&make) {
int hits = 0;
for (int i = 0; i < Runs; ++i) {
hits += make().confirmedBy == u"depth";
}
return hits;
};
std::printf("depth cue\n");
expectRate("head turning 15 degrees, 1 px noise", depthRate([&] { return realHead(15, 1.0, rng); }), Runs, 0.9, 1.0);
expectRate("head turning 15 degrees, 2 px noise", depthRate([&] { return realHead(15, 2.0, rng); }), Runs, 0.6, 1.0);
expectRate("head turning 25 degrees, 2 px noise", depthRate([&] { return realHead(25, 2.0, rng); }), Runs, 0.9, 1.0);
expectRate("heads of all shapes turning 12 to 22 degrees", depthRate([&] { return variedHead(rng); }), Runs, 0.9, 1.0);
expectRate("head held still (2 degrees), 1 px noise", depthRate([&] { return realHead(2, 1.0, rng); }), Runs, 0.0, 0.05);
expectRate("photo turned 30 degrees, 0.5 px noise", depthRate([&] { return photo(30, 0.5, rng); }), Runs, 0.0, 0.0);
expectRate("photo turned 30 degrees, 1 px noise", depthRate([&] { return photo(30, 1.0, rng); }), Runs, 0.0, 0.01);
expectRate("photo turned 30 degrees, 2 px noise", depthRate([&] { return photo(30, 2.0, rng); }), Runs, 0.0, 0.03);
expectRate("photo turned 45 degrees, 1 px noise", depthRate([&] { return photo(45, 1.0, rng); }), Runs, 0.0, 0.01);
expectRate("photo of a turned head, turned 30 degrees", depthRate([&] { return photo(30, 1.0, rng, 20); }), Runs, 0.0, 0.01);
expectRate("photo curled by 5 mm, turned 30 degrees", depthRate([&] { return photo(30, 1.0, rng, 0, 5); }), Runs, 0.0, 0.05);
// Curled this hard, a card has about a quarter of a real nose's depth, and
// turned twice as far as a head would be it moves its "nose" as far. Five
// points cannot tell that from a very flat face turned a little. Blink,
// glare and the device edge are what is left against it.
report("known limit: photo curled by 15 mm, turned 30 degrees", depthRate([&] { return photo(30, 1.0, rng, 0, 15); }), Runs);
expectRate("photo turned 60 degrees, 3 px noise", depthRate([&] { return photo(60, 3.0, rng); }), Runs, 0.0, 0.05);
expectRate("photo shaken for 6 seconds, 3 px noise", depthRate([&] { return photo(20, 3.0, rng, 0, 0, 6.0); }), Runs, 0.0, 0.05);
expectRate("head turning 20 degrees, 3 px noise", depthRate([&] { return realHead(20, 3.0, rng); }), Runs, 0.6, 1.0);
std::printf("\nblink cue\n");
auto blinkRate = [&](const EyeScript &s) {
int hits = 0;
for (int i = 0; i < Runs; ++i) {
hits += blinkRun(s, rng);
}
return hits;
};
const auto open = [](int) { return 0.45f; };
const auto blink = [](int i) { return (i >= 30 && i < 34) ? 0.08f : 0.45f; };
const auto slowClose = [](int i) { return (i >= 20 && i < 45) ? 0.08f : 0.45f; };
expectRate("eyes open the whole time", blinkRate({open, open}), Runs, 0.0, 0.01);
expectRate("a normal blink (130 ms)", blinkRate({blink, blink}), Runs, 0.95, 1.0);
expectRate("eyes closed for most of a second", blinkRate({slowClose, slowClose}), Runs, 0.0, 0.01);
expectRate("one eye only", blinkRate({blink, open}), Runs, 0.0, 0.01);
expectRate("light dims at the same moment",
blinkRate({blink, blink, [](int i) { return (i >= 30 && i < 34) ? 110.f : 150.f; }}), Runs, 0.0, 0.01);
expectRate("picture jerked sideways at the same moment",
blinkRate({blink, blink, [](int) { return 150.f; },
[](int i) { return i >= 32 ? cv::Point2f(25, 0) : cv::Point2f(0, 0); }}),
Runs, 0.0, 0.01);
std::printf("\n%s\n", failures ? "SOME CHECKS FAILED" : "all checks passed");
return failures ? 1 : 0;
}
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env bash
#
# The PAM file editing, against copies of what the distributions ship.
#
# Turning face unlock on for sudo edits /etc/pam.d/sudo, and a mistake there
# is a machine nobody can sudo on any more. So every layout this has to cope
# with is here: where the line lands, that it lands once, and that turning it
# off gives back the file exactly as it was.
#
# When the pam_harness from the build is there, the files that come out are
# also run through real PAM, with the module missing on purpose: the dash in
# front of the line has to make PAM skip it without a word.
set -uo pipefail
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
tmp="$(mktemp -d)"
trap 'rm -rf -- "$tmp"' EXIT
export PFU_PAM_ETC_DIR="$tmp/etc"
export PFU_PAM_VENDOR_DIRS="$tmp/vendor"
PFU_LIBDIR="$here/src/lib"
# shellcheck source=/dev/null
source "$PFU_LIBDIR/common.sh"
# shellcheck source=/dev/null
source "$PFU_LIBDIR/config.sh"
# shellcheck source=/dev/null
source "$PFU_LIBDIR/pam.sh"
PFU_PAM_MODULE="$tmp/lib/pam_plasma_face_unlock.so"
mkdir -p "$tmp/lib" "$tmp/etc" "$tmp/vendor"
: > "$PFU_PAM_MODULE"
failures=0
check() {
if eval "$2"; then
printf 'ok %s\n' "$1"
else
printf 'FAIL %s\n' "$1"
failures=$(( failures + 1 ))
fi
}
# first_auth <file>: the first line that has anything to do with auth.
first_auth() {
grep -m1 -E '^[[:space:]]*(-?auth[[:space:]]|@include[[:space:]]+common-auth)' "$1"
}
# ---------------------------------------------------------------------------
# The layouts
# ---------------------------------------------------------------------------
arch_sudo='#%PAM-1.0
auth include system-auth
account include system-auth
session include system-auth
session optional pam_systemd.so class=none'
debian_sudo='#%PAM-1.0
# Set up user limits from /etc/security/limits.conf.
session required pam_limits.so
session required pam_env.so readenv=1 user_readenv=0
session required pam_env.so readenv=1 envfile=/etc/default/locale user_readenv=0
@include common-auth
@include common-account
@include common-session-noninteractive'
fedora_sudo='#%PAM-1.0
auth include system-auth
account include system-auth
password include system-auth
session optional pam_keyinit.so revoke
session required pam_limits.so
session include system-auth'
arch_polkit='#%PAM-1.0
auth include system-auth
account include system-auth
password include system-auth
session include system-auth'
for layout in arch_sudo debian_sudo fedora_sudo; do
printf '%s\n' "${!layout}" > "$tmp/etc/sudo"
cp "$tmp/etc/sudo" "$tmp/original"
pfu_pam_enable sudo
check "$layout: turned on" 'pfu_pam_enabled sudo'
check "$layout: the face comes before the password" '[[ "$(first_auth "$tmp/etc/sudo")" == *pam_plasma_face_unlock.so* ]]'
check "$layout: with the dash and as sufficient" 'grep -qE "^-auth[[:space:]]+sufficient[[:space:]]+$PFU_PAM_MODULE\$" "$tmp/etc/sudo"'
check "$layout: the header stays first" '[[ "$(head -n1 "$tmp/etc/sudo")" == "#%PAM-1.0" ]]'
pfu_pam_enable sudo
check "$layout: turning it on twice adds it once" '[[ $(grep -c pam_plasma_face_unlock "$tmp/etc/sudo") -eq 1 ]]'
pfu_pam_disable sudo
check "$layout: turning it off gives back the same file" 'cmp -s "$tmp/etc/sudo" "$tmp/original"'
check "$layout: turned off" '! pfu_pam_enabled sudo'
rm -f "$tmp/etc/sudo"
done
# Only the distribution's copy, in /usr/lib/pam.d.
printf '%s\n' "$arch_polkit" > "$tmp/vendor/polkit-1"
pfu_pam_enable polkit-1
check "vendor polkit-1: a small file of our own in /etc" '[[ -f $tmp/etc/polkit-1 ]] && grep -qF "$PFU_PAM_WRAPPER_MARK" "$tmp/etc/polkit-1"'
check "vendor polkit-1: it includes the distribution's file" 'grep -qE "^auth[[:space:]]+include[[:space:]]+$tmp/vendor/polkit-1\$" "$tmp/etc/polkit-1"'
check "vendor polkit-1: face first" '[[ "$(first_auth "$tmp/etc/polkit-1")" == *pam_plasma_face_unlock.so* ]]'
check "vendor polkit-1: the distribution's file is left alone" '[[ "$(cat "$tmp/vendor/polkit-1")" == "$arch_polkit" ]]'
pfu_pam_disable polkit-1
check "vendor polkit-1: turning it off removes our file" '[[ ! -e $tmp/etc/polkit-1 ]]'
# No configuration at all.
check "an unknown service is refused" '! pfu_pam_enable nosuchservice 2>/dev/null'
# ---------------------------------------------------------------------------
# Through real PAM
# ---------------------------------------------------------------------------
harness="$here/build/pam_harness"
if [[ -x $harness ]]; then
mkdir -p "$tmp/real/vendor" "$tmp/real/etc"
printf 'auth required pam_permit.so\naccount required pam_permit.so\n' > "$tmp/real/vendor/svc"
PFU_PAM_ETC_DIR="$tmp/real/etc"
PFU_PAM_VENDOR_DIRS=("$tmp/real/vendor")
PFU_PAM_MODULE="$tmp/real/missing/pam_plasma_face_unlock.so"
mkdir -p "$tmp/real/missing" && : > "$PFU_PAM_MODULE"
pfu_pam_enable svc
rm -f "$PFU_PAM_MODULE"
check "a missing module is skipped, the rest of the stack still decides" '"$harness" "$tmp/real/etc" svc "$(id -un)" > /dev/null 2>&1'
printf 'auth required pam_deny.so\n' > "$tmp/real/vendor/svc"
check "and a stack that says no still says no" '! "$harness" "$tmp/real/etc" svc "$(id -un)" > /dev/null 2>&1'
else
printf 'skip real PAM checks (build pam_harness first)\n'
fi
printf '\n%s\n' "$( (( failures )) && echo "SOME CHECKS FAILED" || echo "all checks passed")"
(( failures == 0 ))
+121
View File
@@ -0,0 +1,121 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The face store: what goes in comes back out, matching picks the right
// sample, and learning from unlocks stays within its bounds.
#include "store.h"
#include <QFileInfo>
#include <QTemporaryDir>
#include <cmath>
#include <cstdio>
#include <random>
namespace
{
int failures = 0;
void check(bool ok, const char *what)
{
std::printf("%s %s\n", ok ? "ok " : "FAIL", what);
failures += !ok;
}
Embedding randomUnit(std::mt19937 &rng)
{
std::normal_distribution<float> n;
Embedding e(Vision::EmbeddingSize);
float norm = 0;
for (float &v : e) {
v = n(rng);
norm += v * v;
}
for (float &v : e) {
v /= std::sqrt(norm);
}
return e;
}
// A vector about `similarity` away from `base`.
Embedding near(const Embedding &base, float similarity, std::mt19937 &rng)
{
Embedding other = randomUnit(rng);
Embedding out(base.size());
const float w = std::sqrt(1 - similarity * similarity);
float norm = 0;
for (size_t i = 0; i < base.size(); ++i) {
out[i] = similarity * base[i] + w * other[i];
norm += out[i] * out[i];
}
for (float &v : out) {
v /= std::sqrt(norm);
}
return out;
}
} // namespace
int main()
{
std::mt19937 rng(7);
QTemporaryDir dir;
const FaceStore store(dir.path());
Identity a;
a.id = FaceStore::newId();
a.name = QStringLiteral("Felix mit Brille");
a.created = 1758550000;
a.noseT = 0.57f;
a.eyes = 0.41f;
const Embedding faceA = randomUnit(rng);
for (int i = 0; i < 5; ++i) {
a.samples.append({near(faceA, 0.8f, rng), QStringLiteral("center"), 1758550000 + i});
}
Identity b;
b.id = FaceStore::newId();
b.name = QStringLiteral("Other");
const Embedding faceB = randomUnit(rng);
b.samples.append({faceB, QStringLiteral("center"), 1});
QString error;
check(store.save(1000, {a, b}, &error), "save");
const QString file = dir.filePath(QStringLiteral("users/1000.json"));
check(QFileInfo(file).permissions() == (QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ReadUser | QFileDevice::WriteUser),
"the file is readable by its owner only");
const QList<Identity> loaded = store.load(1000, &error);
check(loaded.size() == 2, "both faces come back");
check(loaded.value(0).name == a.name && loaded.value(0).samples.size() == 5, "names and samples survive");
check(std::abs(loaded.value(0).noseT - 0.57f) < 1e-6f && std::abs(loaded.value(0).eyes - 0.41f) < 1e-6f, "attention baselines survive");
check(Vision::similarity(loaded.value(0).samples.value(0).embedding, a.samples.value(0).embedding) > 0.9999f, "embeddings survive exactly");
check(store.load(1001).isEmpty(), "another user has no faces");
const FaceMatch m = FaceStore::bestMatch(loaded, near(faceA, 0.9f, rng));
check(m.identity == 0 && m.score > 0.6f, "the right face matches");
const FaceMatch stranger = FaceStore::bestMatch(loaded, randomUnit(rng));
check(stranger.score < 0.4f, "a stranger does not");
QList<Identity> disabled = loaded;
disabled[0].enabled = false;
check(FaceStore::bestMatch(disabled, near(faceA, 0.9f, rng)).identity != 0, "a face that is turned off never matches");
Identity learning = loaded.value(0);
check(!FaceStore::adapt(learning, learning.samples.value(0).embedding, 5), "nothing new: nothing learned");
int added = 0;
for (int i = 0; i < 40; ++i) {
added += FaceStore::adapt(learning, near(faceA, 0.6f, rng), 10 + i);
}
check(added > FaceStore::MaxAdaptive, "new looks are learned");
check(learning.adaptiveCount() == FaceStore::MaxAdaptive, "but only so many of them are kept");
int setup = 0;
for (const FaceSample &s : learning.samples) {
setup += s.pose != u"adaptive";
}
check(setup == 5, "the samples from the setup are never replaced");
check(store.save(1000, {}, &error) && !QFileInfo::exists(file), "saving nothing removes the file");
std::printf("\n%s\n", failures ? "SOME CHECKS FAILED" : "all checks passed");
return failures ? 1 : 0;
}