mirror of https://github.com/cutefishos/settings
feat(accounts): add a password changer that answers passwd's prompts on a terminal
parent
db62494402
commit
06cec04ae5
@ -0,0 +1,324 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "pages/accounts/passwordchanger.h"
|
||||
|
||||
#include <cerrno>
|
||||
#include <csignal>
|
||||
#include <cstring>
|
||||
#include <fcntl.h>
|
||||
#include <poll.h>
|
||||
#include <spawn.h>
|
||||
#include <stdlib.h>
|
||||
#include <sys/epoll.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
#include <vector>
|
||||
|
||||
#include "base/event_loop.h"
|
||||
#include "base/paths.h"
|
||||
|
||||
using namespace cutefish;
|
||||
|
||||
namespace settings {
|
||||
|
||||
// Through syscall(): glibc 2.41's sys/pidfd.h lacks the C linkage C++ needs.
|
||||
static int openPidfd(pid_t pid)
|
||||
{
|
||||
return int(syscall(SYS_pidfd_open, pid, 0));
|
||||
}
|
||||
|
||||
static void closeFd(int& fd)
|
||||
{
|
||||
if (fd >= 0)
|
||||
::close(fd);
|
||||
fd = -1;
|
||||
}
|
||||
|
||||
static void wipe(std::string& secret)
|
||||
{
|
||||
explicit_bzero(secret.data(), secret.size());
|
||||
secret.clear();
|
||||
}
|
||||
|
||||
static std::string trimmed(const std::string& text)
|
||||
{
|
||||
const size_t first = text.find_first_not_of(" \t\r\n");
|
||||
if (first == std::string::npos)
|
||||
return {};
|
||||
const size_t last = text.find_last_not_of(" \t\r\n");
|
||||
return text.substr(first, last - first + 1);
|
||||
}
|
||||
|
||||
// What passwd said, without its prompts and its closing "password unchanged".
|
||||
static std::string passwdMessage(const std::string& output)
|
||||
{
|
||||
std::string message;
|
||||
size_t start = 0;
|
||||
while (start < output.size()) {
|
||||
size_t end = output.find('\n', start);
|
||||
if (end == std::string::npos)
|
||||
end = output.size();
|
||||
std::string line = trimmed(output.substr(start, end - start));
|
||||
start = end + 1;
|
||||
if (line.starts_with("passwd: "))
|
||||
line.erase(0, 8);
|
||||
if (line.empty() || line.ends_with("password:") || line == "password unchanged"
|
||||
|| line.starts_with("Changing password for "))
|
||||
continue;
|
||||
if (!message.empty())
|
||||
message += ' ';
|
||||
message += line;
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
PasswordChanger::PasswordChanger(std::string program, std::chrono::milliseconds timeout, base::EventLoop* loop)
|
||||
: m_loop(loop ? *loop : *base::EventLoop::current())
|
||||
, m_program(std::move(program))
|
||||
, m_timeout(timeout)
|
||||
, m_silence(&m_loop)
|
||||
{
|
||||
m_silence.setSingleShot(true);
|
||||
m_silence.onTimeout = [this] { stop(Result::TimedOut, {}, SIGKILL); };
|
||||
}
|
||||
|
||||
PasswordChanger::~PasswordChanger()
|
||||
{
|
||||
if (m_pid > 0) {
|
||||
// A passwd writing the password files finishes that first.
|
||||
::kill(m_pid, SIGTERM);
|
||||
pollfd ended = {m_pidfd, POLLIN, 0};
|
||||
if (poll(&ended, 1, 2000) <= 0)
|
||||
::kill(m_pid, SIGKILL);
|
||||
siginfo_t info{};
|
||||
waitid(P_PIDFD, id_t(m_pidfd), &info, WEXITED);
|
||||
m_pid = -1;
|
||||
}
|
||||
closeAll();
|
||||
}
|
||||
|
||||
bool PasswordChanger::start(std::string currentPassword, std::string newPassword)
|
||||
{
|
||||
if (m_pid > 0)
|
||||
return false;
|
||||
m_current = std::move(currentPassword);
|
||||
m_new = std::move(newPassword);
|
||||
m_currentAnswered = false;
|
||||
m_newAnswered = false;
|
||||
m_stopping = false;
|
||||
|
||||
m_master = posix_openpt(O_RDWR | O_NOCTTY | O_CLOEXEC | O_NONBLOCK);
|
||||
char name[64] = {};
|
||||
if (m_master < 0 || grantpt(m_master) != 0 || unlockpt(m_master) != 0
|
||||
|| ptsname_r(m_master, name, sizeof(name)) != 0) {
|
||||
closeAll();
|
||||
return false;
|
||||
}
|
||||
// Held until passwd has the terminal, so that it never hangs up before.
|
||||
int terminal = ::open(name, O_RDWR | O_NOCTTY | O_CLOEXEC);
|
||||
if (terminal < 0) {
|
||||
closeAll();
|
||||
return false;
|
||||
}
|
||||
|
||||
posix_spawn_file_actions_t actions;
|
||||
posix_spawnattr_t attributes;
|
||||
posix_spawn_file_actions_init(&actions);
|
||||
posix_spawnattr_init(&attributes);
|
||||
// In a session of its own, the terminal opened after setsid() becomes
|
||||
// passwd's controlling terminal, where PAM turns echo off.
|
||||
posix_spawnattr_setflags(&attributes, POSIX_SPAWN_SETSID | POSIX_SPAWN_SETSIGMASK | POSIX_SPAWN_SETSIGDEF);
|
||||
sigset_t none;
|
||||
sigemptyset(&none);
|
||||
posix_spawnattr_setsigmask(&attributes, &none);
|
||||
sigset_t defaults;
|
||||
sigfillset(&defaults);
|
||||
posix_spawnattr_setsigdefault(&attributes, &defaults);
|
||||
posix_spawn_file_actions_addopen(&actions, STDIN_FILENO, name, O_RDWR, 0);
|
||||
posix_spawn_file_actions_adddup2(&actions, STDIN_FILENO, STDOUT_FILENO);
|
||||
posix_spawn_file_actions_adddup2(&actions, STDIN_FILENO, STDERR_FILENO);
|
||||
posix_spawn_file_actions_addclosefrom_np(&actions, STDERR_FILENO + 1);
|
||||
|
||||
// English prompts and messages to match. The keyring's password module
|
||||
// finds its daemon under the runtime directory.
|
||||
std::vector<std::string> environment = {"LC_ALL=C", "PATH=/usr/sbin:/usr/bin:/sbin:/bin"};
|
||||
const std::string runtime = base::paths::runtimeDirectory();
|
||||
if (!runtime.empty())
|
||||
environment.push_back("XDG_RUNTIME_DIR=" + runtime);
|
||||
std::vector<char*> envp;
|
||||
for (std::string& entry : environment)
|
||||
envp.push_back(entry.data());
|
||||
envp.push_back(nullptr);
|
||||
char* argv[] = {m_program.data(), nullptr};
|
||||
|
||||
pid_t pid = -1;
|
||||
const int spawned = posix_spawn(&pid, m_program.c_str(), &actions, &attributes, argv, envp.data());
|
||||
posix_spawn_file_actions_destroy(&actions);
|
||||
posix_spawnattr_destroy(&attributes);
|
||||
::close(terminal);
|
||||
if (spawned != 0) {
|
||||
closeAll();
|
||||
return false;
|
||||
}
|
||||
m_pid = pid;
|
||||
m_pidfd = openPidfd(pid);
|
||||
if (m_pidfd < 0) {
|
||||
::kill(pid, SIGKILL);
|
||||
waitpid(pid, nullptr, 0);
|
||||
m_pid = -1;
|
||||
closeAll();
|
||||
return false;
|
||||
}
|
||||
|
||||
m_masterWatch = m_loop.watch(m_master, EPOLLIN, [this](uint32_t) { readOutput(); });
|
||||
m_pidWatch = m_loop.watch(m_pidfd, EPOLLIN, [this](uint32_t) { processEnded(); });
|
||||
m_silence.start(m_timeout);
|
||||
return true;
|
||||
}
|
||||
|
||||
void PasswordChanger::drain()
|
||||
{
|
||||
char buffer[4096];
|
||||
for (;;) {
|
||||
const ssize_t count = ::read(m_master, buffer, sizeof(buffer));
|
||||
if (count > 0) {
|
||||
m_output.append(buffer, size_t(count));
|
||||
if (!m_stopping)
|
||||
m_silence.start(m_timeout);
|
||||
continue;
|
||||
}
|
||||
if (count < 0 && errno == EINTR)
|
||||
continue;
|
||||
// EIO once passwd and everything it started have closed the terminal.
|
||||
if (count == 0 || errno != EAGAIN) {
|
||||
if (m_masterWatch)
|
||||
m_loop.unwatch(m_masterWatch);
|
||||
m_masterWatch = 0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void PasswordChanger::readOutput()
|
||||
{
|
||||
drain();
|
||||
const std::string text = trimmed(m_output);
|
||||
if (text.ends_with("Retype new password:"))
|
||||
handlePrompt(Prompt::Retype);
|
||||
else if (text.ends_with("New password:"))
|
||||
handlePrompt(Prompt::New);
|
||||
// The second is pam_unix's before Linux-PAM 1.4.
|
||||
else if (text.ends_with("Current password:") || text.ends_with("(current) UNIX password:"))
|
||||
handlePrompt(Prompt::Current);
|
||||
}
|
||||
|
||||
void PasswordChanger::handlePrompt(Prompt prompt)
|
||||
{
|
||||
if (m_stopping)
|
||||
return;
|
||||
switch (prompt) {
|
||||
case Prompt::Current:
|
||||
if (m_currentAnswered) {
|
||||
stop(Result::WrongPassword, {}, SIGTERM);
|
||||
return;
|
||||
}
|
||||
m_currentAnswered = true;
|
||||
answer(m_current);
|
||||
break;
|
||||
case Prompt::New:
|
||||
// Asked again: the last one was refused, and passwd said why.
|
||||
if (m_newAnswered) {
|
||||
stop(Result::Rejected, passwdMessage(m_output), SIGTERM);
|
||||
return;
|
||||
}
|
||||
m_newAnswered = true;
|
||||
answer(m_new);
|
||||
break;
|
||||
case Prompt::Retype:
|
||||
answer(m_new);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void PasswordChanger::answer(const std::string& text)
|
||||
{
|
||||
m_output.clear();
|
||||
std::string line = text + '\n';
|
||||
size_t written = 0;
|
||||
while (written < line.size()) {
|
||||
const ssize_t count = ::write(m_master, line.data() + written, line.size() - written);
|
||||
if (count < 0 && errno == EINTR)
|
||||
continue;
|
||||
if (count <= 0)
|
||||
break;
|
||||
written += size_t(count);
|
||||
}
|
||||
const bool complete = written == line.size();
|
||||
wipe(line);
|
||||
if (!complete)
|
||||
stop(Result::Failed, {}, SIGTERM);
|
||||
}
|
||||
|
||||
void PasswordChanger::processEnded()
|
||||
{
|
||||
if (m_masterWatch)
|
||||
drain();
|
||||
siginfo_t info{};
|
||||
if (waitid(P_PIDFD, id_t(m_pidfd), &info, WEXITED | WNOHANG) != 0 || info.si_pid == 0)
|
||||
return;
|
||||
m_pid = -1;
|
||||
const bool exited = info.si_code == CLD_EXITED;
|
||||
const std::string message = passwdMessage(m_output);
|
||||
|
||||
Result result;
|
||||
std::string said;
|
||||
if (m_stopping) {
|
||||
result = m_stopResult;
|
||||
said = m_stopMessage;
|
||||
} else if (exited && info.si_status == 0) {
|
||||
result = Result::Changed;
|
||||
} else if (!m_newAnswered
|
||||
&& (m_output.find("Authentication token manipulation error") != std::string::npos
|
||||
|| m_output.find("Authentication failure") != std::string::npos)) {
|
||||
result = Result::WrongPassword;
|
||||
} else {
|
||||
result = Result::Failed;
|
||||
said = message;
|
||||
}
|
||||
closeAll();
|
||||
|
||||
const auto finished = onFinished;
|
||||
if (finished)
|
||||
finished(result, said);
|
||||
}
|
||||
|
||||
void PasswordChanger::stop(Result result, std::string message, int signal)
|
||||
{
|
||||
if (m_stopping || m_pid <= 0)
|
||||
return;
|
||||
m_stopping = true;
|
||||
m_stopResult = result;
|
||||
m_stopMessage = std::move(message);
|
||||
m_silence.stop();
|
||||
::kill(m_pid, signal);
|
||||
}
|
||||
|
||||
void PasswordChanger::closeAll()
|
||||
{
|
||||
m_silence.stop();
|
||||
if (m_masterWatch)
|
||||
m_loop.unwatch(m_masterWatch);
|
||||
if (m_pidWatch)
|
||||
m_loop.unwatch(m_pidWatch);
|
||||
m_masterWatch = 0;
|
||||
m_pidWatch = 0;
|
||||
closeFd(m_master);
|
||||
closeFd(m_pidfd);
|
||||
wipe(m_current);
|
||||
wipe(m_new);
|
||||
wipe(m_output);
|
||||
}
|
||||
|
||||
} // namespace settings
|
||||
@ -0,0 +1,88 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <sys/types.h>
|
||||
|
||||
#include "base/timer.h"
|
||||
|
||||
namespace cutefish::base {
|
||||
class EventLoop;
|
||||
}
|
||||
|
||||
namespace settings {
|
||||
|
||||
// Changes the user's own password through passwd(1) on a pseudo-terminal,
|
||||
// answering its prompts. passwd asks PAM, so the system's password rules
|
||||
// apply and the password modules, such as the login keyring's, follow the
|
||||
// change. One change at a time.
|
||||
class PasswordChanger
|
||||
{
|
||||
public:
|
||||
enum class Result {
|
||||
Changed,
|
||||
// The current password was not accepted.
|
||||
WrongPassword,
|
||||
// The new password was refused, by the password rules for instance.
|
||||
Rejected,
|
||||
Failed,
|
||||
// passwd stopped answering.
|
||||
TimedOut,
|
||||
};
|
||||
|
||||
// `program` is passwd, or a stand-in that asks the same questions.
|
||||
// `timeout` is how long passwd may stay silent.
|
||||
explicit PasswordChanger(std::string program = "/usr/bin/passwd",
|
||||
std::chrono::milliseconds timeout = std::chrono::seconds(20),
|
||||
cutefish::base::EventLoop* loop = nullptr);
|
||||
// Ends a passwd still running and waits for it.
|
||||
~PasswordChanger();
|
||||
|
||||
PasswordChanger(const PasswordChanger&) = delete;
|
||||
PasswordChanger& operator=(const PasswordChanger&) = delete;
|
||||
|
||||
// False when passwd cannot be started; onFinished follows otherwise.
|
||||
bool start(std::string currentPassword, std::string newPassword);
|
||||
bool isRunning() const { return m_pid > 0; }
|
||||
|
||||
// `message` is what passwd said, for Rejected and Failed; may be empty.
|
||||
// The object may be destroyed from it.
|
||||
std::function<void(Result result, const std::string& message)> onFinished;
|
||||
|
||||
private:
|
||||
enum class Prompt { Current, New, Retype };
|
||||
|
||||
void drain();
|
||||
void readOutput();
|
||||
void handlePrompt(Prompt prompt);
|
||||
void answer(const std::string& text);
|
||||
void processEnded();
|
||||
// Ends passwd; its end then reports `result`.
|
||||
void stop(Result result, std::string message, int signal);
|
||||
void closeAll();
|
||||
|
||||
cutefish::base::EventLoop& m_loop;
|
||||
std::string m_program;
|
||||
std::chrono::milliseconds m_timeout;
|
||||
cutefish::base::Timer m_silence;
|
||||
std::string m_current;
|
||||
std::string m_new;
|
||||
pid_t m_pid = -1;
|
||||
int m_pidfd = -1;
|
||||
int m_master = -1;
|
||||
int m_pidWatch = 0;
|
||||
int m_masterWatch = 0;
|
||||
// What passwd wrote since the last answer.
|
||||
std::string m_output;
|
||||
bool m_currentAnswered = false;
|
||||
bool m_newAnswered = false;
|
||||
bool m_stopping = false;
|
||||
Result m_stopResult = Result::Failed;
|
||||
std::string m_stopMessage;
|
||||
};
|
||||
|
||||
} // namespace settings
|
||||
@ -0,0 +1,171 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <stdlib.h>
|
||||
#include <string>
|
||||
#include <sys/stat.h>
|
||||
|
||||
#include "pages/accounts/passwordchanger.h"
|
||||
#include "input_test_support.h"
|
||||
|
||||
using namespace cutefish;
|
||||
using namespace settings;
|
||||
using namespace std::chrono_literals;
|
||||
|
||||
namespace {
|
||||
|
||||
// Asks as passwd does through PAM on a terminal: the current password is
|
||||
// "old", a new one of fewer than four characters is refused and asked again.
|
||||
// What it is finally given goes to `changed` next to it.
|
||||
const char* const kPasswd = R"(#!/bin/sh
|
||||
[ -t 0 ] && stty -echo || exit 3
|
||||
[ "$LC_ALL" = C ] || exit 4
|
||||
printf 'Changing password for test.\n'
|
||||
printf 'Current password: '
|
||||
read -r current
|
||||
printf '\n'
|
||||
if [ "$current" != old ]; then
|
||||
printf 'passwd: Authentication token manipulation error\npasswd: password unchanged\n'
|
||||
exit 10
|
||||
fi
|
||||
while :; do
|
||||
printf 'New password: '
|
||||
read -r new
|
||||
printf '\n'
|
||||
if [ ${#new} -lt 4 ]; then
|
||||
printf 'You must choose a longer password.\n'
|
||||
continue
|
||||
fi
|
||||
printf 'Retype new password: '
|
||||
read -r again
|
||||
printf '\n'
|
||||
if [ "$new" != "$again" ]; then
|
||||
printf 'Sorry, passwords do not match.\n'
|
||||
continue
|
||||
fi
|
||||
printf '%s' "$new" > "$(dirname "$0")/changed"
|
||||
printf 'passwd: password updated successfully\n'
|
||||
exit 0
|
||||
done
|
||||
)";
|
||||
|
||||
// A passwd that asks for the current password and then dies.
|
||||
const char* const kDyingPasswd = R"(#!/bin/sh
|
||||
printf 'Current password: '
|
||||
read -r current
|
||||
kill -KILL $$
|
||||
)";
|
||||
|
||||
// A passwd that asks for the current password and then hangs.
|
||||
const char* const kHangingPasswd = R"(#!/bin/sh
|
||||
printf 'Current password: '
|
||||
read -r current
|
||||
exec sleep 60
|
||||
)";
|
||||
|
||||
class FakePasswd
|
||||
{
|
||||
public:
|
||||
explicit FakePasswd(const char* script)
|
||||
{
|
||||
char pattern[] = "/tmp/settings-passwd-XXXXXX";
|
||||
m_directory = mkdtemp(pattern);
|
||||
path = m_directory + "/passwd";
|
||||
std::ofstream(path) << script;
|
||||
chmod(path.c_str(), 0755);
|
||||
}
|
||||
~FakePasswd() { std::filesystem::remove_all(m_directory); }
|
||||
|
||||
std::string changed() const
|
||||
{
|
||||
std::ifstream file(m_directory + "/changed");
|
||||
return std::string(std::istreambuf_iterator<char>(file), {});
|
||||
}
|
||||
|
||||
std::string path;
|
||||
|
||||
private:
|
||||
std::string m_directory;
|
||||
};
|
||||
|
||||
struct Outcome
|
||||
{
|
||||
PasswordChanger::Result result;
|
||||
std::string message;
|
||||
};
|
||||
|
||||
std::optional<Outcome> change(const std::string& program, const std::string& current, const std::string& password,
|
||||
std::chrono::milliseconds timeout = 5s)
|
||||
{
|
||||
PasswordChanger changer(program, timeout);
|
||||
std::optional<Outcome> outcome;
|
||||
changer.onFinished = [&](PasswordChanger::Result result, const std::string& message) {
|
||||
outcome = Outcome{result, message};
|
||||
};
|
||||
if (!changer.start(current, password))
|
||||
return std::nullopt;
|
||||
EXPECT_TRUE(changer.isRunning());
|
||||
settings::testing::waitFor([&] { return outcome.has_value(); }, 5000ms);
|
||||
EXPECT_FALSE(changer.isRunning());
|
||||
return outcome;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(AccountsPassword, ChangesThroughPasswdsPrompts)
|
||||
{
|
||||
FakePasswd passwd(kPasswd);
|
||||
const auto outcome = change(passwd.path, "old", "new password");
|
||||
ASSERT_TRUE(outcome);
|
||||
EXPECT_EQ(outcome->result, PasswordChanger::Result::Changed);
|
||||
EXPECT_EQ(passwd.changed(), "new password");
|
||||
}
|
||||
|
||||
TEST(AccountsPassword, ReportsAWrongCurrentPassword)
|
||||
{
|
||||
FakePasswd passwd(kPasswd);
|
||||
const auto outcome = change(passwd.path, "wrong", "new password");
|
||||
ASSERT_TRUE(outcome);
|
||||
EXPECT_EQ(outcome->result, PasswordChanger::Result::WrongPassword);
|
||||
EXPECT_EQ(passwd.changed(), "");
|
||||
}
|
||||
|
||||
TEST(AccountsPassword, ReportsWhyTheNewPasswordWasRefused)
|
||||
{
|
||||
FakePasswd passwd(kPasswd);
|
||||
const auto outcome = change(passwd.path, "old", "abc");
|
||||
ASSERT_TRUE(outcome);
|
||||
EXPECT_EQ(outcome->result, PasswordChanger::Result::Rejected);
|
||||
EXPECT_EQ(outcome->message, "You must choose a longer password.");
|
||||
EXPECT_EQ(passwd.changed(), "");
|
||||
}
|
||||
|
||||
TEST(AccountsPassword, ReportsPasswdDying)
|
||||
{
|
||||
FakePasswd passwd(kDyingPasswd);
|
||||
const auto outcome = change(passwd.path, "old", "new password");
|
||||
ASSERT_TRUE(outcome);
|
||||
EXPECT_EQ(outcome->result, PasswordChanger::Result::Failed);
|
||||
}
|
||||
|
||||
TEST(AccountsPassword, GivesUpOnASilentPasswd)
|
||||
{
|
||||
FakePasswd passwd(kHangingPasswd);
|
||||
const auto outcome = change(passwd.path, "old", "new password", 300ms);
|
||||
ASSERT_TRUE(outcome);
|
||||
EXPECT_EQ(outcome->result, PasswordChanger::Result::TimedOut);
|
||||
}
|
||||
|
||||
TEST(AccountsPassword, AMissingPasswdDoesNotStart)
|
||||
{
|
||||
PasswordChanger changer("/nonexistent/passwd");
|
||||
EXPECT_FALSE(changer.start("old", "new password"));
|
||||
EXPECT_FALSE(changer.isRunning());
|
||||
}
|
||||
Loading…
Reference in New Issue