feat(accounts): add a password changer that answers passwd's prompts on a terminal

main
reionwong 2 weeks ago
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…
Cancel
Save