// SPDX-License-Identifier: GPL-3.0-or-later // // pam_face_unlock: face unlock for sudo, admin prompts and lock screens. // // All the vision is in the daemon. This asks it to scan for the user and // turns the answer into a PAM result, and it is meant to sit first in a stack // as "auth sufficient": a match lets the person in, anything else falls // through to the password as if the module was not there. // // It refuses to be useful in exactly the places where a camera is the wrong // witness: // - a remote login (SSH, a remote host in PAM_RHOST). Whoever is in front // of the camera need not be the person typing. The daemon refuses it // unless SshSessions is on in the system settings. // - a user who is not sitting at the machine right now, with an active // session on a seat. Then it returns PAM_IGNORE without touching the // camera. // // Options: // purpose=sudo|polkit|other what the bubble says (default: from the // service name) // lockscreen in a lock screen's stack (hyprlock, swaylock, // gtklock...): see is_lock_starting() // socket=PATH the daemon's socket (for development) // timeout=SECONDS give up waiting for the daemon after this // debug log to the auth log what happened #define _GNU_SOURCE #define PAM_SM_AUTH #include "buildconfig.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #ifdef HAVE_SYSTEMD #include #include #endif #define DOMAIN FU_NAME // Marks a message for translation. say() translates it. #define _(s) (s) struct options { const char *socket; const char *purpose; int timeout; bool lockscreen; bool debug; }; static void parse_options(struct options *o, int argc, const char **argv) { o->socket = FU_SOCKET; o->purpose = NULL; o->timeout = 25; o->lockscreen = false; o->debug = false; for (int i = 0; i < argc; ++i) { if (strncmp(argv[i], "socket=", 7) == 0) { o->socket = argv[i] + 7; } else if (strncmp(argv[i], "purpose=", 8) == 0) { o->purpose = argv[i] + 8; } else if (strncmp(argv[i], "timeout=", 8) == 0) { o->timeout = atoi(argv[i] + 8); if (o->timeout < 3 || o->timeout > 120) { o->timeout = 25; } } else if (strcmp(argv[i], "lockscreen") == 0) { o->lockscreen = true; } else if (strcmp(argv[i], "debug") == 0) { o->debug = true; } } } static long long now_ms(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); return (long long)ts.tv_sec * 1000 + ts.tv_nsec / 1000000; } static bool nonempty(const char *s) { return s && *s; } // hyprlock asks PAM the moment it starts, before anybody pressed a key. // Scanning then would open the screen again for whoever just locked it on // purpose while still sitting in front of it. So a lock screen that is less // than two seconds old gets no scan; pressing Enter asks again, and the agent // scans when somebody comes back. static bool is_lock_starting(void) { FILE *stat = fopen("/proc/self/stat", "r"); FILE *uptime = fopen("/proc/uptime", "r"); unsigned long long started = 0; double up = 0; bool ok = stat && uptime && fscanf(uptime, "%lf", &up) == 1; if (ok) { // After the name in brackets, which can hold anything, the start // time is the 20th field. char line[1024]; ok = fgets(line, sizeof(line), stat) != NULL; const char *p = ok ? strrchr(line, ')') : NULL; ok = p && sscanf(p + 2, "%*c %*d %*d %*d %*d %*d %*u %*u %*u %*u %*u %*u %*u %*d %*d %*d %*d %*d %*d %llu", &started) == 1; } if (stat) { fclose(stat); } if (uptime) { fclose(uptime); } const long ticks = sysconf(_SC_CLK_TCK); return ok && ticks > 0 && up - (double)started / (double)ticks < 2.0; } // Whoever types this is not whoever sits in front of the camera. static bool is_remote(pam_handle_t *pamh) { const void *rhost = NULL; if (pam_get_item(pamh, PAM_RHOST, &rhost) == PAM_SUCCESS && nonempty(rhost) && strcmp(rhost, "localhost") != 0) { return true; } static const char *const vars[] = {"SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"}; for (size_t i = 0; i < sizeof(vars) / sizeof(vars[0]); ++i) { if (nonempty(getenv(vars[i])) || nonempty(pam_getenv(pamh, vars[i]))) { return true; } } #ifdef HAVE_SYSTEMD char *session = NULL; if (sd_pid_get_session(0, &session) >= 0 && session) { const bool remote = sd_session_is_remote(session) > 0; free(session); if (remote) { return true; } } #endif return false; } // The person has to be at the machine: an active session on a seat. static bool is_at_seat(const char *user) { #ifdef HAVE_SYSTEMD struct passwd pw, *result = NULL; char buf[4096]; if (getpwnam_r(user, &pw, buf, sizeof(buf), &result) != 0 || !result) { return false; } // The number of seats the user is active on, whatever they are called. return sd_uid_get_seats(result->pw_uid, 1, NULL) > 0; #else (void)user; return true; #endif } static int connect_daemon(const struct options *o, pam_handle_t *pamh) { struct sockaddr_un addr = {.sun_family = AF_UNIX}; if (strlen(o->socket) >= sizeof(addr.sun_path)) { return -1; } strcpy(addr.sun_path, o->socket); const int fd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0); if (fd < 0) { return -1; } struct timeval tv = {.tv_sec = 3}; setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) { if (o->debug) { pam_syslog(pamh, LOG_DEBUG, "cannot reach the daemon at %s: %s", o->socket, strerror(errno)); } close(fd); return -1; } // Only root may answer for root. When this runs as somebody else (the // tests), a daemon of that same user is no less trusted than the process // asking. struct ucred cred; socklen_t len = sizeof(cred); if (getsockopt(fd, SOL_SOCKET, SO_PEERCRED, &cred, &len) != 0 || (cred.uid != 0 && cred.uid != geteuid())) { pam_syslog(pamh, LOG_WARNING, "refusing a face unlock daemon that does not run as root"); close(fd); return -1; } return fd; } // The daemon's answers are compact JSON objects with plain values. It runs as // root and is the one party here that is trusted, so this only has to be // correct for well formed input and safe for anything else. static bool json_string(const char *line, const char *key, char *out, size_t size) { char pattern[64]; snprintf(pattern, sizeof(pattern), "\"%s\":\"", key); const char *p = strstr(line, pattern); if (!p || size == 0) { return false; } p += strlen(pattern); size_t n = 0; while (*p && *p != '"' && n + 1 < size) { if (*p == '\\' && p[1]) { ++p; } out[n++] = *p++; } out[n] = '\0'; return true; } static bool json_true(const char *line, const char *key) { char pattern[64]; snprintf(pattern, sizeof(pattern), "\"%s\":true", key); return strstr(line, pattern) != NULL; } // Admin prompts run this in polkit's helper. It keeps LANG but never calls // setlocale, so gettext would stay in English. Take the language from the // environment for this thread while the message goes out. static void say(pam_handle_t *pamh, int flags, const char *message) { if (flags & PAM_SILENT) { return; } locale_t loc = (locale_t)0, old = (locale_t)0; const char *global = setlocale(LC_MESSAGES, NULL); if (!global || strcmp(global, "C") == 0 || strcmp(global, "POSIX") == 0) { loc = newlocale(LC_MESSAGES_MASK, "", (locale_t)0); if (loc) { old = uselocale(loc); } } pam_info(pamh, "%s", dgettext(DOMAIN, message)); if (loc) { uselocale(old); freelocale(loc); } } __attribute__((visibility("default"))) PAM_EXTERN int pam_sm_authenticate(pam_handle_t *pamh, int flags, int argc, const char **argv) { struct options o; parse_options(&o, argc, argv); bindtextdomain(DOMAIN, FU_LOCALEDIR); const char *user = NULL; if (pam_get_user(pamh, &user, NULL) != PAM_SUCCESS || !nonempty(user)) { return PAM_IGNORE; } const void *service = NULL; pam_get_item(pamh, PAM_SERVICE, &service); const char *purpose = o.purpose; if (!purpose && o.lockscreen) { purpose = "lockscreen"; } if (!purpose) { purpose = !service ? "other" : strncmp(service, "sudo", 4) == 0 ? "sudo" : strcmp(service, "polkit-1") == 0 ? "polkit" : "other"; } if (o.lockscreen && is_lock_starting()) { if (o.debug) { pam_syslog(pamh, LOG_DEBUG, "the lock screen has only just started, not scanning for %s", user); } return PAM_IGNORE; } const bool remote = is_remote(pamh); if (!is_at_seat(user)) { if (o.debug) { pam_syslog(pamh, LOG_DEBUG, "%s is not at the machine, not scanning", user); } return PAM_IGNORE; } const int fd = connect_daemon(&o, pamh); if (fd < 0) { return PAM_IGNORE; } char request[512]; const int len = snprintf(request, sizeof(request), "{\"cmd\":\"verify\",\"user\":\"%s\",\"purpose\":\"%s\",\"remote\":%s}\n", user, purpose, remote ? "true" : "false"); if (len <= 0 || (size_t)len >= sizeof(request) || strpbrk(user, "\"\\") || write(fd, request, (size_t)len) != len) { close(fd); return PAM_IGNORE; } const long long deadline = now_ms() + (long long)o.timeout * 1000; char buf[8192]; size_t used = 0; int rc = PAM_AUTHINFO_UNAVAIL; bool done = false; bool told = false; while (!done) { const long long left = deadline - now_ms(); if (left <= 0) { break; } struct pollfd pfd = {.fd = fd, .events = POLLIN}; const int ready = poll(&pfd, 1, (int)left); if (ready < 0 && errno == EINTR) { // Ctrl+C in sudo. Stop the camera and go on to the password. break; } if (ready <= 0) { break; } const ssize_t got = read(fd, buf + used, sizeof(buf) - 1 - used); if (got <= 0) { break; } used += (size_t)got; buf[used] = '\0'; char *line = buf; char *nl; while ((nl = strchr(line, '\n'))) { *nl = '\0'; char event[32] = "", value[128] = ""; json_string(line, "event", event, sizeof(event)); if (strcmp(event, "started") == 0 && !told) { told = true; say(pamh, flags, _("Look at the camera to unlock.")); } else if (strcmp(event, "hint") == 0 && json_string(line, "hint", value, sizeof(value))) { if (strcmp(value, "blink") == 0) { say(pamh, flags, _("Blink, or turn your head a little.")); } else if (strcmp(value, "look") == 0) { say(pamh, flags, _("Look at the screen.")); } else if (strcmp(value, "closer") == 0) { say(pamh, flags, _("Move closer to the camera.")); } else if (strcmp(value, "light") == 0) { say(pamh, flags, _("It is too dark to see your face.")); } } else if (strcmp(event, "result") == 0) { done = true; json_string(line, "reason", value, sizeof(value)); if (json_true(line, "ok")) { rc = PAM_SUCCESS; pam_syslog(pamh, LOG_NOTICE, "face recognised for %s (%s)", user, purpose); } else if (strcmp(value, "lockout") == 0 || strstr(line, "\"lockout\":")) { rc = PAM_AUTH_ERR; say(pamh, flags, _("Face unlock is paused after too many tries. Use your password.")); } else if (strcmp(value, "mismatch") == 0 || strcmp(value, "spoof") == 0 || strcmp(value, "liveness") == 0) { rc = PAM_AUTH_ERR; pam_syslog(pamh, LOG_NOTICE, "face not recognised for %s (%s)", user, value); say(pamh, flags, _("Face not recognised.")); } else if (strcmp(value, "no-face") == 0 || strcmp(value, "attention") == 0 || strcmp(value, "quality") == 0) { rc = PAM_AUTH_ERR; } else { // Not set up, no camera, the camera in a video call, // lid shut, busy, SSH: face unlock is simply not // available right now. rc = PAM_AUTHINFO_UNAVAIL; if (o.debug) { pam_syslog(pamh, LOG_DEBUG, "face unlock unavailable for %s: %s", user, value); } } break; } line = nl + 1; } // Keep what is left of an unfinished line. used = strlen(line); memmove(buf, line, used + 1); if (used >= sizeof(buf) - 1) { break; } } close(fd); return rc; } __attribute__((visibility("default"))) PAM_EXTERN int pam_sm_setcred(pam_handle_t *pamh, int flags, int argc, const char **argv) { (void)pamh; (void)flags; (void)argc; (void)argv; return PAM_IGNORE; }