service/pam: move pam execution to subprocess to allow killing it

Many pam modules can't be aborted well without this.
This commit is contained in:
outfoxxed 2024-06-18 03:29:25 -07:00
parent b5c8774a79
commit e89035b18c
Signed by: outfoxxed
GPG Key ID: 4C88A185FB89301E
10 changed files with 480 additions and 173 deletions

View File

@ -28,6 +28,7 @@ Checks: >
-modernize-return-braced-init-list,
-modernize-use-trailing-return-type,
performance-*,
-performance-avoid-endl,
portability-std-allocator-const,
readability-*,
-readability-function-cognitive-complexity,

View File

@ -3,6 +3,8 @@
qt_add_library(quickshell-service-pam STATIC
qml.cpp
conversation.cpp
ipc.cpp
subprocess.cpp
)
qt_add_qml_module(quickshell-service-pam
URI Quickshell.Services.Pam

View File

@ -1,21 +1,22 @@
#include "conversation.hpp"
#include <utility>
#include <qlogging.h>
#include <qloggingcategory.h>
#include <qmutex.h>
#include <qobject.h>
#include <qsocketnotifier.h>
#include <qstring.h>
#include <qtmetamacros.h>
#include <security/_pam_types.h>
#include <security/pam_appl.h>
#include <sys/wait.h>
#include "ipc.hpp"
Q_LOGGING_CATEGORY(logPam, "quickshell.service.pam", QtWarningMsg);
QString PamError::toString(PamError::Enum value) {
switch (value) {
case ConnectionFailed: return "Failed to connect to pam";
case StartFailed: return "Failed to start the PAM session";
case TryAuthFailed: return "Failed to try authenticating";
case InternalError: return "Internal error occurred";
default: return "Invalid error";
}
}
@ -26,160 +27,116 @@ QString PamResult::toString(PamResult::Enum value) {
case Failed: return "Failed";
case Error: return "Error occurred while authenticating";
case MaxTries: return "The authentication method has no more attempts available";
// case Expired: return "The account has expired";
// case PermissionDenied: return "Permission denied";
default: return "Invalid result";
}
}
void PamConversation::run() {
auto conv = pam_conv {
.conv = &PamConversation::conversation,
.appdata_ptr = this,
};
PamConversation::~PamConversation() { this->abort(); }
pam_handle_t* handle = nullptr;
qCInfo(logPam) << this << "Starting pam session for user" << this->user << "with config"
<< this->config << "in configdir" << this->configDir;
auto result = pam_start_confdir(
this->config.toStdString().c_str(),
this->user.toStdString().c_str(),
&conv,
this->configDir.toStdString().c_str(),
&handle
);
if (result != PAM_SUCCESS) {
qCCritical(logPam) << this << "Unable to start pam conversation with error"
<< QString(pam_strerror(handle, result));
emit this->error(PamError::ConnectionFailed);
this->deleteLater();
void PamConversation::start(const QString& configDir, const QString& config, const QString& user) {
this->childPid = PamConversation::createSubprocess(&this->pipes, configDir, config, user);
if (this->childPid == 0) {
qCCritical(logPam) << "Failed to create pam subprocess.";
emit this->error(PamError::InternalError);
return;
}
result = pam_authenticate(handle, 0);
// Seems to require root and quickshell should not run as root.
// if (result == PAM_SUCCESS) {
// result = pam_acct_mgmt(handle, 0);
// }
switch (result) {
case PAM_SUCCESS:
qCInfo(logPam) << this << "ended with successful authentication.";
emit this->completed(PamResult::Success);
break;
case PAM_AUTH_ERR:
qCInfo(logPam) << this << "ended with failed authentication.";
emit this->completed(PamResult::Failed);
break;
case PAM_MAXTRIES:
qCInfo(logPam) << this << "ended with failure: max tries.";
emit this->completed(PamResult::MaxTries);
break;
/*case PAM_ACCT_EXPIRED:
qCInfo(logPam) << this << "ended with failure: account expiration.";
emit this->completed(PamResult::Expired);
break;
case PAM_PERM_DENIED:
qCInfo(logPam) << this << "ended with failure: permission denied.";
emit this->completed(PamResult::PermissionDenied);
break;*/
default:
qCCritical(logPam) << this << "ended with error:" << QString(pam_strerror(handle, result));
emit this->error(PamError::TryAuthFailed);
break;
}
result = pam_end(handle, result);
if (result != PAM_SUCCESS) {
qCCritical(logPam) << this << "Failed to end pam conversation with error code"
<< QString(pam_strerror(handle, result));
}
this->deleteLater();
QObject::connect(&this->notifier, &QSocketNotifier::activated, this, &PamConversation::onMessage);
this->notifier.setSocket(this->pipes.fdIn);
this->notifier.setEnabled(true);
}
void PamConversation::abort() {
qCDebug(logPam) << "Abort requested for" << this;
auto locker = QMutexLocker(&this->wakeMutex);
this->mAbort = true;
this->waker.wakeOne();
if (this->childPid != 0) {
qCDebug(logPam) << "Killing subprocess for" << this;
kill(this->childPid, SIGKILL); // NOLINT (include)
waitpid(this->childPid, nullptr, 0);
this->childPid = 0;
}
}
void PamConversation::respond(QString response) {
qCDebug(logPam) << "Set response for" << this;
auto locker = QMutexLocker(&this->wakeMutex);
this->response = std::move(response);
this->hasResponse = true;
this->waker.wakeOne();
void PamConversation::internalError() {
if (this->childPid != 0) {
qCDebug(logPam) << "Killing subprocess for" << this;
kill(this->childPid, SIGKILL); // NOLINT (include)
waitpid(this->childPid, nullptr, 0);
this->childPid = 0;
emit this->error(PamError::InternalError);
}
}
int PamConversation::conversation(
int msgCount,
const pam_message** msgArray,
pam_response** responseArray,
void* appdata
) {
auto* delegate = static_cast<PamConversation*>(appdata);
void PamConversation::respond(const QString& response) {
qCDebug(logPam) << "Sending response for" << this;
if (!this->pipes.writeString(response.toStdString())) {
qCCritical(logPam) << "Failed to write response to subprocess.";
this->internalError();
}
}
void PamConversation::onMessage() {
{
auto locker = QMutexLocker(&delegate->wakeMutex);
if (delegate->mAbort) {
return PAM_ERROR_MSG;
}
}
qCDebug(logPam) << "Got message from subprocess.";
// freed by libc so must be alloc'd by it.
auto* responses = static_cast<pam_response*>(calloc(msgCount, sizeof(pam_response))); // NOLINT
auto type = PamIpcEvent::Exit;
for (auto i = 0; i < msgCount; i++) {
const auto* message = msgArray[i]; // NOLINT
auto& response = responses[i]; // NOLINT
auto ok = this->pipes.readBytes(
reinterpret_cast<char*>(&type), // NOLINT
sizeof(PamIpcEvent)
);
auto msgString = QString(message->msg);
auto messageChanged = true; // message->msg_style != PAM_PROMPT_ECHO_OFF;
auto isError = message->msg_style == PAM_ERROR_MSG;
auto responseRequired =
message->msg_style == PAM_PROMPT_ECHO_OFF || message->msg_style == PAM_PROMPT_ECHO_ON;
if (!ok) goto fail;
qCDebug(logPam) << delegate << "got new message message:" << msgString
<< "messageChanged:" << messageChanged << "isError:" << isError
<< "responseRequired" << responseRequired;
if (type == PamIpcEvent::Exit) {
auto code = PamIpcExitCode::OtherError;
delegate->hasResponse = false;
emit delegate->message(msgString, messageChanged, isError, responseRequired);
ok = this->pipes.readBytes(
reinterpret_cast<char*>(&code), // NOLINT
sizeof(PamIpcExitCode)
);
{
auto locker = QMutexLocker(&delegate->wakeMutex);
if (!ok) goto fail;
if (delegate->mAbort) {
free(responses); // NOLINT
return PAM_ERROR_MSG;
qCDebug(logPam) << "Subprocess exited with code" << static_cast<int>(code);
switch (code) {
case PamIpcExitCode::Success: emit this->completed(PamResult::Success); break;
case PamIpcExitCode::AuthFailed: emit this->completed(PamResult::Failed); break;
case PamIpcExitCode::StartFailed: emit this->error(PamError::StartFailed); break;
case PamIpcExitCode::MaxTries: emit this->completed(PamResult::MaxTries); break;
case PamIpcExitCode::PamError: emit this->error(PamError::TryAuthFailed); break;
case PamIpcExitCode::OtherError: emit this->error(PamError::InternalError); break;
}
if (responseRequired) {
if (!delegate->hasResponse) {
delegate->waker.wait(locker.mutex());
waitpid(this->childPid, nullptr, 0);
this->childPid = 0;
} else if (type == PamIpcEvent::Request) {
PamIpcRequestFlags flags {};
if (delegate->mAbort) {
free(responses); // NOLINT
return PAM_ERROR_MSG;
}
}
ok = this->pipes.readBytes(
reinterpret_cast<char*>(&flags), // NOLINT
sizeof(PamIpcRequestFlags)
);
if (!delegate->hasResponse) {
qCCritical(logPam
) << "Pam conversation requires response and does not have one. This should not happen.";
}
if (!ok) goto fail;
response.resp = strdup(delegate->response.toStdString().c_str()); // NOLINT (include error)
}
auto message = this->pipes.readString(&ok);
if (!ok) goto fail;
this->message(
QString::fromUtf8(message),
/*flags.echo*/ true,
flags.error,
flags.responseRequired
);
} else {
qCCritical(logPam) << "Unexpected message from subprocess.";
goto fail;
}
}
return;
*responseArray = responses;
return PAM_SUCCESS;
fail:
qCCritical(logPam) << "Failed to read subprocess request.";
this->internalError();
}

View File

@ -2,13 +2,16 @@
#include <utility>
#include <qmutex.h>
#include <qloggingcategory.h>
#include <qobject.h>
#include <qqmlintegration.h>
#include <qthread.h>
#include <qsocketnotifier.h>
#include <qtclasshelpermacros.h>
#include <qtmetamacros.h>
#include <qwaitcondition.h>
#include <security/pam_appl.h>
#include "ipc.hpp"
Q_DECLARE_LOGGING_CATEGORY(logPam);
/// The result of an authentication.
class PamResult: public QObject {
@ -26,10 +29,6 @@ public:
Error = 2,
/// The authentication method ran out of tries and should not be used again.
MaxTries = 3,
// The account has expired.
// Expired 4,
// Permission denied.
// PermissionDenied 5,
};
Q_ENUM(Enum);
@ -44,52 +43,56 @@ class PamError: public QObject {
public:
enum Enum {
/// Failed to initiate the pam connection.
ConnectionFailed = 1,
/// Failed to start the pam session.
StartFailed = 1,
/// Failed to try to authenticate the user.
/// This is not the same as the user failing to authenticate.
TryAuthFailed = 2,
/// An error occurred inside quickshell's pam interface.
InternalError = 3,
};
Q_ENUM(Enum);
Q_INVOKABLE static QString toString(PamError::Enum value);
};
class PamConversation: public QThread {
// PAM has no way to abort a running module except when it sends a message,
// meaning aborts for things like fingerprint scanners
// and hardware keys don't actually work without aborting the process...
// so we have a subprocess.
class PamConversation: public QObject {
Q_OBJECT;
public:
explicit PamConversation(QString config, QString configDir, QString user)
: config(std::move(config))
, configDir(std::move(configDir))
, user(std::move(user)) {}
explicit PamConversation(QObject* parent): QObject(parent) {}
~PamConversation() override;
Q_DISABLE_COPY_MOVE(PamConversation);
public:
void run() override;
void start(const QString& configDir, const QString& config, const QString& user);
void abort();
void respond(QString response);
void respond(const QString& response);
signals:
void completed(PamResult::Enum result);
void error(PamError::Enum error);
void message(QString message, bool messageChanged, bool isError, bool responseRequired);
private slots:
void onMessage();
private:
static int conversation(
int msgCount,
const pam_message** msgArray,
pam_response** responseArray,
void* appdata
static pid_t createSubprocess(
PamIpcPipes* pipes,
const QString& configDir,
const QString& config,
const QString& user
);
QString config;
QString configDir;
QString user;
void internalError();
QMutex wakeMutex;
QWaitCondition waker;
bool mAbort = false;
bool hasResponse = false;
QString response;
pid_t childPid = 0;
PamIpcPipes pipes;
QSocketNotifier notifier {QSocketNotifier::Read};
};

69
src/services/pam/ipc.cpp Normal file
View File

@ -0,0 +1,69 @@
#include "ipc.hpp"
#include <cstddef>
#include <cstdint>
#include <string>
#include <unistd.h>
PamIpcPipes::~PamIpcPipes() {
if (this->fdIn != 0) close(this->fdIn);
if (this->fdOut != 0) close(this->fdOut);
}
bool PamIpcPipes::readBytes(char* buffer, size_t length) const {
size_t i = 0;
while (i < length) {
auto count = read(this->fdIn, buffer + i, length - i); // NOLINT
if (count == -1 || count == 0) {
return false;
}
i += count;
}
return true;
}
bool PamIpcPipes::writeBytes(const char* buffer, size_t length) const {
size_t i = 0;
while (i < length) {
auto count = write(this->fdOut, buffer + i, length - i); // NOLINT
if (count == -1 || count == 0) {
return false;
}
i += count;
}
return true;
}
std::string PamIpcPipes::readString(bool* ok) const {
if (ok != nullptr) *ok = false;
uint32_t length = 0;
if (!this->readBytes(reinterpret_cast<char*>(&length), sizeof(length))) { // NOLINT
return "";
}
char data[length]; // NOLINT
if (!this->readBytes(data, length)) {
return "";
}
if (ok != nullptr) *ok = true;
return std::string(data, length);
}
bool PamIpcPipes::writeString(const std::string& string) const {
uint32_t length = string.length();
if (!this->writeBytes(reinterpret_cast<char*>(&length), sizeof(length))) { // NOLINT
return false;
}
return this->writeBytes(string.data(), string.length());
}

43
src/services/pam/ipc.hpp Normal file
View File

@ -0,0 +1,43 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include <qtclasshelpermacros.h>
enum class PamIpcEvent : uint8_t {
Request,
Exit,
};
enum class PamIpcExitCode : uint8_t {
Success,
StartFailed,
AuthFailed,
MaxTries,
PamError,
OtherError,
};
struct PamIpcRequestFlags {
bool echo;
bool error;
bool responseRequired;
};
class PamIpcPipes {
public:
explicit PamIpcPipes() = default;
explicit PamIpcPipes(int fdIn, int fdOut): fdIn(fdIn), fdOut(fdOut) {}
~PamIpcPipes();
Q_DISABLE_COPY_MOVE(PamIpcPipes);
[[nodiscard]] bool readBytes(char* buffer, size_t length) const;
[[nodiscard]] bool writeBytes(const char* buffer, size_t length) const;
[[nodiscard]] std::string readString(bool* ok) const;
[[nodiscard]] bool writeString(const std::string& string) const;
int fdIn = 0;
int fdOut = 0;
};

View File

@ -13,12 +13,6 @@
#include "conversation.hpp"
PamContext::~PamContext() {
if (this->conversation != nullptr && this->conversation->isRunning()) {
this->conversation->abort();
}
}
void PamContext::componentComplete() {
this->postInit = true;
@ -91,12 +85,12 @@ void PamContext::startConversation() {
}
}
this->conversation = new PamConversation(this->mConfig, this->mConfigDirectory, user);
this->conversation = new PamConversation(this);
QObject::connect(this->conversation, &PamConversation::completed, this, &PamContext::onCompleted);
QObject::connect(this->conversation, &PamConversation::error, this, &PamContext::onError);
QObject::connect(this->conversation, &PamConversation::message, this, &PamContext::onMessage);
emit this->activeChanged();
this->conversation->start();
this->conversation->start(this->mConfigDirectory, this->mConfig, user);
}
void PamContext::abortConversation() {
@ -104,7 +98,7 @@ void PamContext::abortConversation() {
this->mTargetActive = false;
QObject::disconnect(this->conversation, nullptr, this, nullptr);
if (this->conversation->isRunning()) this->conversation->abort();
this->conversation->deleteLater();
this->conversation = nullptr;
emit this->activeChanged();
@ -124,9 +118,9 @@ void PamContext::abortConversation() {
}
}
void PamContext::respond(QString response) {
void PamContext::respond(const QString& response) {
if (this->isActive() && this->mIsResponseRequired) {
this->conversation->respond(std::move(response));
this->conversation->respond(response);
} else {
qWarning() << "PamContext response was ignored as this context does not require one.";
}

View File

@ -51,8 +51,6 @@ class PamContext
public:
explicit PamContext(QObject* parent = nullptr): QObject(parent) {}
~PamContext() override;
Q_DISABLE_COPY_MOVE(PamContext);
void classBegin() override {}
void componentComplete() override;
@ -69,7 +67,7 @@ public:
/// Respond to pam.
///
/// May not be called unless `responseRequired` is true.
Q_INVOKABLE void respond(QString response);
Q_INVOKABLE void respond(const QString& response);
[[nodiscard]] bool isActive() const;
void setActive(bool active);

View File

@ -0,0 +1,209 @@
#include "subprocess.hpp"
#include <array>
#include <ostream>
#include <string>
#include <qlogging.h>
#include <qloggingcategory.h>
#include <qstring.h>
#include <sched.h>
#include <security/_pam_types.h>
#include <security/pam_appl.h>
#include <unistd.h>
#include "conversation.hpp"
#include "ipc.hpp"
pid_t PamConversation::createSubprocess(
PamIpcPipes* pipes,
const QString& configDir,
const QString& config,
const QString& user
) {
auto toSubprocess = std::array<int, 2>();
auto fromSubprocess = std::array<int, 2>();
if (pipe(toSubprocess.data()) == -1 || pipe(fromSubprocess.data()) == -1) {
qCDebug(logPam) << "Failed to create pipes for subprocess.";
return 0;
}
auto* configDirF = strdup(configDir.toStdString().c_str()); // NOLINT (include)
auto* configF = strdup(config.toStdString().c_str()); // NOLINT (include)
auto* userF = strdup(user.toStdString().c_str()); // NOLINT (include)
auto log = logPam().isDebugEnabled();
auto pid = fork();
if (pid < 0) {
qCDebug(logPam) << "Failed to fork for subprocess.";
} else if (pid == 0) {
close(toSubprocess[1]); // close w
close(fromSubprocess[0]); // close r
{
auto subprocess = PamSubprocess(log, toSubprocess[0], fromSubprocess[1]);
auto code = subprocess.exec(configDirF, configF, userF);
subprocess.sendCode(code);
}
free(configDirF); // NOLINT
free(configF); // NOLINT
free(userF); // NOLINT
// do not do cleanup that may affect the parent
_exit(0);
} else {
close(toSubprocess[0]); // close r
close(fromSubprocess[1]); // close w
pipes->fdIn = fromSubprocess[0];
pipes->fdOut = toSubprocess[1];
free(configDirF); // NOLINT
free(configF); // NOLINT
free(userF); // NOLINT
return pid;
}
return -1; // should never happen but lint
}
PamIpcExitCode PamSubprocess::exec(const char* configDir, const char* config, const char* user) {
logIf(this->log) << "Waiting for parent confirmation..." << std::endl;
auto conv = pam_conv {
.conv = &PamSubprocess::conversation,
.appdata_ptr = this,
};
pam_handle_t* handle = nullptr;
logIf(this->log) << "Starting pam session for user \"" << user << "\" with config \"" << config
<< "\" in dir \"" << configDir << "\"" << std::endl;
auto result = pam_start_confdir(config, user, &conv, configDir, &handle);
if (result != PAM_SUCCESS) {
logIf(true) << "Unable to start pam conversation with error \"" << pam_strerror(handle, result)
<< "\" (code " << result << ")" << std::endl;
return PamIpcExitCode::StartFailed;
}
result = pam_authenticate(handle, 0);
PamIpcExitCode code = PamIpcExitCode::OtherError;
switch (result) {
case PAM_SUCCESS:
logIf(this->log) << "Authenticated successfully." << std::endl;
code = PamIpcExitCode::Success;
break;
case PAM_AUTH_ERR:
logIf(this->log) << "Failed to authenticate." << std::endl;
code = PamIpcExitCode::AuthFailed;
break;
case PAM_MAXTRIES:
logIf(this->log) << "Failed to authenticate due to hitting max tries." << std::endl;
code = PamIpcExitCode::MaxTries;
break;
default:
logIf(true) << "Error while authenticating: \"" << pam_strerror(handle, result) << "\" (code "
<< result << ")" << std::endl;
code = PamIpcExitCode::PamError;
break;
}
result = pam_end(handle, result);
if (result != PAM_SUCCESS) {
logIf(true) << "Error in pam_end: \"" << pam_strerror(handle, result) << "\" (code " << result
<< ")" << std::endl;
}
return code;
}
void PamSubprocess::sendCode(PamIpcExitCode code) {
{
auto eventType = PamIpcEvent::Exit;
auto ok = this->pipes.writeBytes(
reinterpret_cast<char*>(&eventType), // NOLINT
sizeof(PamIpcEvent)
);
if (!ok) goto fail;
ok = this->pipes.writeBytes(
reinterpret_cast<char*>(&code), // NOLINT
sizeof(PamIpcExitCode)
);
if (!ok) goto fail;
return;
}
fail:
_exit(1);
}
int PamSubprocess::conversation(
int msgCount,
const pam_message** msgArray,
pam_response** responseArray,
void* appdata
) {
auto* delegate = static_cast<PamSubprocess*>(appdata);
// freed by libc so must be alloc'd by it.
auto* responses = static_cast<pam_response*>(calloc(msgCount, sizeof(pam_response))); // NOLINT
for (auto i = 0; i < msgCount; i++) {
const auto* message = msgArray[i]; // NOLINT
auto& response = responses[i]; // NOLINT
auto msgString = std::string(message->msg);
auto req = PamIpcRequestFlags {
.echo = message->msg_style != PAM_PROMPT_ECHO_OFF,
.error = message->msg_style == PAM_ERROR_MSG,
.responseRequired =
message->msg_style == PAM_PROMPT_ECHO_OFF || message->msg_style == PAM_PROMPT_ECHO_ON,
};
logIf(delegate->log) << "Relaying pam message: \"" << msgString << "\" echo: " << req.echo
<< " error: " << req.error << " responseRequired: " << req.responseRequired
<< std::endl;
auto eventType = PamIpcEvent::Request;
auto ok = delegate->pipes.writeBytes(
reinterpret_cast<char*>(&eventType), // NOLINT
sizeof(PamIpcEvent)
);
if (!ok) goto fail;
ok = delegate->pipes.writeBytes(
reinterpret_cast<const char*>(&req), // NOLINT
sizeof(PamIpcRequestFlags)
);
if (!ok) goto fail;
if (!delegate->pipes.writeString(msgString)) goto fail;
if (req.responseRequired) {
auto ok = false;
auto resp = delegate->pipes.readString(&ok);
if (!ok) _exit(static_cast<int>(PamIpcExitCode::OtherError));
logIf(delegate->log) << "Got response for request.";
response.resp = strdup(resp.c_str()); // NOLINT (include)
}
}
*responseArray = responses;
return PAM_SUCCESS;
fail:
free(responseArray); // NOLINT
_exit(1);
}

View File

@ -0,0 +1,31 @@
#pragma once
#include <iostream>
#include <security/pam_appl.h>
#include "ipc.hpp"
// endls are intentional as it makes debugging much easier when the buffer actually flushes.
// NOLINTBEGIN
#define logIf(log) \
if (log) std::cout << "quickshell.service.pam.subprocess: "
// NOLINTEND
class PamSubprocess {
public:
explicit PamSubprocess(bool log, int fdIn, int fdOut): log(log), pipes(fdIn, fdOut) {}
PamIpcExitCode exec(const char* configDir, const char* config, const char* user);
void sendCode(PamIpcExitCode code);
private:
static int conversation(
int msgCount,
const pam_message** msgArray,
pam_response** responseArray,
void* appdata
);
bool log;
PamIpcPipes pipes;
};