#include "ProvisioningBackend.h" #include "Config.h" #include #include #include #include #include #include #include #include #include #include #include #include namespace { constexpr int kHelperTimeoutMs = 120000; bool containsLineBreak(const QString &value) { return value.contains(QLatin1Char('\n')) || value.contains(QLatin1Char('\r')); } QString effectiveHomeDirectory() { if (passwd *entry = getpwuid(geteuid())) { const QString home = QString::fromLocal8Bit(entry->pw_dir); if (!home.isEmpty() && home != QStringLiteral("/")) { return home; } } return QDir::homePath(); } } ProvisioningBackend::ProvisioningBackend(QString targetUser, QObject *parent) : QObject(parent) , m_currentUser(effectiveUserName()) , m_targetUser(std::move(targetUser)) { refreshState(); appendLog(QStringLiteral("START currentUser=%1 targetUser=%2 authorized=%3 passwordDone=%4 pinDone=%5 fidoDone=%6 yubiKeyChoice=%7 yubiKeyRequested=%8") .arg(m_currentUser, m_targetUser, authorizedUser() ? QStringLiteral("yes") : QStringLiteral("no"), m_passwordDone ? QStringLiteral("yes") : QStringLiteral("no"), m_pinDone ? QStringLiteral("yes") : QStringLiteral("no"), m_fidoDone ? QStringLiteral("yes") : QStringLiteral("no"), yubiKeyChoiceMade() ? QStringLiteral("yes") : QStringLiteral("no"), yubiKeyRequested() ? QStringLiteral("yes") : QStringLiteral("no"))); } ProvisioningBackend::~ProvisioningBackend() { secureClear(m_pendingPayload); if (m_process && m_process->state() != QProcess::NotRunning) { m_process->kill(); m_process->waitForFinished(1000); } } QString ProvisioningBackend::effectiveUserName() { if (passwd *entry = getpwuid(geteuid())) { return QString::fromLocal8Bit(entry->pw_name); } return {}; } bool ProvisioningBackend::authorizedUser() const { if (m_targetUser.isEmpty()) { return false; } const QByteArray name = m_targetUser.toLocal8Bit(); if (passwd *entry = getpwnam(name.constData())) { return entry->pw_uid == geteuid(); } return false; } QString ProvisioningBackend::stateDirectory() const { const QByteArray xdgState = qgetenv("XDG_STATE_HOME"); if (!xdgState.isEmpty()) { const QString candidate = QString::fromLocal8Bit(xdgState); if (QDir::isAbsolutePath(candidate)) { return QDir::cleanPath(candidate + QStringLiteral("/nixos-workstations")); } } return QDir::cleanPath(effectiveHomeDirectory() + QStringLiteral("/.local/state/nixos-workstations")); } QString ProvisioningBackend::diagnosticLogPath() const { return stateDirectory() + QStringLiteral("/setup.log"); } QString ProvisioningBackend::passwordMarker() const { return stateDirectory() + QStringLiteral("/password-initialized"); } QString ProvisioningBackend::pinMarker() const { return stateDirectory() + QStringLiteral("/yubikey-pin-created"); } QString ProvisioningBackend::fidoMarker() const { return stateDirectory() + QStringLiteral("/yubikey-fido-enrolled"); } QString ProvisioningBackend::yubiKeyEnabledMarker() const { return stateDirectory() + QStringLiteral("/yubikey-enabled"); } QString ProvisioningBackend::yubiKeySkippedMarker() const { return stateDirectory() + QStringLiteral("/yubikey-skipped"); } void ProvisioningBackend::refreshState() { const bool oldPassword = m_passwordDone; const bool oldPin = m_pinDone; const bool oldFido = m_fidoDone; const bool oldYubiKeyEnabled = m_yubiKeyEnabled; const bool oldYubiKeySkipped = m_yubiKeySkipped; m_passwordDone = QFileInfo::exists(passwordMarker()); m_pinDone = QFileInfo::exists(pinMarker()); m_fidoDone = QFileInfo::exists(fidoMarker()); m_yubiKeyEnabled = QFileInfo::exists(yubiKeyEnabledMarker()); m_yubiKeySkipped = QFileInfo::exists(yubiKeySkippedMarker()); if (oldPassword != m_passwordDone || oldPin != m_pinDone || oldFido != m_fidoDone || oldYubiKeyEnabled != m_yubiKeyEnabled || oldYubiKeySkipped != m_yubiKeySkipped) { emit stateChanged(); } } bool ProvisioningBackend::ensureStateWritable() { const QString dir = stateDirectory(); if (!QDir().mkpath(dir)) { return false; } QFile::setPermissions(dir, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner); const QString probePath = dir + QStringLiteral("/.write-test"); QFile probe(probePath); if (!probe.open(QIODevice::WriteOnly | QIODevice::Truncate)) { return false; } const bool ok = probe.write("ok\n") == 3; probe.close(); probe.remove(); return ok; } bool ProvisioningBackend::createMarker(const QString &path) { if (!ensureStateWritable()) { return false; } QSaveFile marker(path); if (!marker.open(QIODevice::WriteOnly)) { return false; } const QByteArray content = QByteArrayLiteral("completed=") + QDateTime::currentDateTimeUtc().toString(Qt::ISODate).toUtf8() + '\n'; if (marker.write(content) != content.size()) { marker.cancelWriting(); return false; } if (!marker.commit()) { return false; } return QFile::setPermissions(path, QFileDevice::ReadOwner | QFileDevice::WriteOwner); } void ProvisioningBackend::appendLog(const QString &message) const { const QString dir = stateDirectory(); if (!QDir().mkpath(dir)) { return; } QFile::setPermissions(dir, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner); QFile log(diagnosticLogPath()); if (!log.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text)) { return; } log.write(QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs).toUtf8() + QByteArrayLiteral(" ") + message.toUtf8() + '\n'); log.close(); QFile::setPermissions(diagnosticLogPath(), QFileDevice::ReadOwner | QFileDevice::WriteOwner); } void ProvisioningBackend::setBusy(bool busy) { if (m_busy == busy) { return; } m_busy = busy; emit busyChanged(); } void ProvisioningBackend::resetFidoInteraction() { const bool changed = m_fidoTouchRequired || m_fidoTouchRequestCount != 0 || !m_fidoStatus.isEmpty(); m_fidoTouchRequired = false; m_fidoTouchRequestCount = 0; m_fidoStatus.clear(); m_helperStdoutBuffer.clear(); if (changed) { emit fidoInteractionChanged(); } } void ProvisioningBackend::handleHelperStdout(Operation operation, const QByteArray &data) { if (operation != Operation::Fido || data.isEmpty()) { return; } m_helperStdoutBuffer += data; qsizetype newlineIndex = -1; while ((newlineIndex = m_helperStdoutBuffer.indexOf('\n')) >= 0) { QByteArray line = m_helperStdoutBuffer.left(newlineIndex); m_helperStdoutBuffer.remove(0, newlineIndex + 1); if (!line.isEmpty() && line.endsWith('\r')) { line.chop(1); } bool changed = false; if (line == QByteArrayLiteral("EVENT_FIDO_INITIALIZING")) { m_fidoTouchRequired = true; ++m_fidoTouchRequestCount; m_fidoStatus = QStringLiteral("Touchez la YubiKey dès qu’elle clignote. Plusieurs touchers peuvent être demandés pendant cette étape. (%1)") .arg(m_fidoTouchRequestCount); changed = true; } else if (line == QByteArrayLiteral("EVENT_FIDO_GENERATING")) { m_fidoTouchRequired = true; ++m_fidoTouchRequestCount; m_fidoStatus = QStringLiteral("Touchez de nouveau la YubiKey dès qu’elle clignote. (%1)") .arg(m_fidoTouchRequestCount); changed = true; } else if (line == QByteArrayLiteral("EVENT_FIDO_UPDATING_HOME")) { m_fidoTouchRequired = false; m_fidoStatus = QStringLiteral("Mise à jour de votre espace personnel…"); changed = true; } else if (line == QByteArrayLiteral("EVENT_FIDO_SYNCHRONIZING")) { m_fidoTouchRequired = false; m_fidoStatus = QStringLiteral("Finalisation de la protection…"); changed = true; } else if (line == QByteArrayLiteral("EVENT_FIDO_TOUCH_REQUIRED")) { m_fidoTouchRequired = true; ++m_fidoTouchRequestCount; m_fidoStatus = QStringLiteral("Touchez la YubiKey maintenant. Plusieurs touchers peuvent être demandés pendant cette étape. (%1)") .arg(m_fidoTouchRequestCount); changed = true; } if (changed) { emit fidoInteractionChanged(); } } } void ProvisioningBackend::secureClear(QByteArray &data) { if (!data.isEmpty()) { volatile char *p = data.data(); for (qsizetype i = 0; i < data.size(); ++i) { p[i] = 0; } } data.clear(); data.squeeze(); } QString ProvisioningBackend::operationName(Operation operation) { switch (operation) { case Operation::Password: return QStringLiteral("PASSWORD"); case Operation::Pin: return QStringLiteral("PIN"); case Operation::Fido: return QStringLiteral("FIDO"); } return QStringLiteral("UNKNOWN"); } void ProvisioningBackend::emitResult(Operation operation, bool success, const QString &message) { switch (operation) { case Operation::Password: emit passwordChangeFinished(success, message); break; case Operation::Pin: emit pinChangeFinished(success, message); break; case Operation::Fido: emit fidoEnrollmentFinished(success, message); break; } } void ProvisioningBackend::setYubiKeyUsage(bool enabled) { if (!authorizedUser()) { emit yubiKeyChoiceFinished(false, QStringLiteral("Cette opération n'est pas autorisée pour cet utilisateur.")); return; } if (m_busy) { emit yubiKeyChoiceFinished(false, QStringLiteral("Une opération de configuration est déjà en cours.")); return; } // Un compte déjà passé par les étapes YubiKey est considéré comme utilisant // toujours sa clé. Cela assure la compatibilité avec les états v1.9.0. if (!enabled && (m_pinDone || m_fidoDone)) { emit yubiKeyChoiceFinished(false, QStringLiteral("Une YubiKey est déjà configurée pour ce compte.")); return; } const QString selectedMarker = enabled ? yubiKeyEnabledMarker() : yubiKeySkippedMarker(); const QString obsoleteMarker = enabled ? yubiKeySkippedMarker() : yubiKeyEnabledMarker(); if (!createMarker(selectedMarker)) { emit yubiKeyChoiceFinished(false, QStringLiteral("Impossible d'enregistrer votre choix.")); return; } QFile::remove(obsoleteMarker); refreshState(); appendLog(QStringLiteral("YUBIKEY choice=%1").arg(enabled ? QStringLiteral("enabled") : QStringLiteral("skipped"))); emit yubiKeyChoiceFinished(true, QString()); } void ProvisioningBackend::changePassword(const QString ¤tPassword, const QString &newPassword, const QString &confirmation) { if (!authorizedUser()) { emit passwordChangeFinished(false, QStringLiteral("Cette opération n'est pas autorisée pour cet utilisateur.")); return; } if (m_busy) { emit passwordChangeFinished(false, QStringLiteral("Une opération de configuration est déjà en cours.")); return; } if (!yubiKeyChoiceMade()) { emit passwordChangeFinished(false, QStringLiteral("Indiquez d'abord si vous disposez d'une YubiKey.")); return; } if (m_passwordDone) { emit passwordChangeFinished(true, QStringLiteral("Le mot de passe a déjà été personnalisé.")); return; } if (currentPassword.isEmpty() || newPassword.isEmpty() || confirmation.isEmpty()) { emit passwordChangeFinished(false, QStringLiteral("Tous les champs du mot de passe doivent être renseignés.")); return; } if (newPassword != confirmation) { emit passwordChangeFinished(false, QStringLiteral("Les deux nouveaux mots de passe ne correspondent pas.")); return; } if (containsLineBreak(currentPassword) || containsLineBreak(newPassword) || containsLineBreak(confirmation)) { emit passwordChangeFinished(false, QStringLiteral("Le mot de passe contient un caractère non pris en charge.")); return; } if (!ensureStateWritable()) { emit passwordChangeFinished(false, QStringLiteral("Impossible d'enregistrer l'état de configuration dans votre profil.")); return; } startHelper(Operation::Password, QString::fromUtf8(NIXOS_WORKSTATIONS_PASSWORD_HELPER_PATH), currentPassword, newPassword, confirmation); } void ProvisioningBackend::initializePin(const QString &newPin, const QString &confirmation) { if (!authorizedUser()) { emit pinChangeFinished(false, QStringLiteral("Cette opération n'est pas autorisée pour cet utilisateur.")); return; } if (m_busy) { emit pinChangeFinished(false, QStringLiteral("Une opération de configuration est déjà en cours.")); return; } if (!yubiKeyRequested()) { emit pinChangeFinished(false, QStringLiteral("Aucune YubiKey n'a été sélectionnée pour ce compte.")); return; } if (!m_passwordDone) { emit pinChangeFinished(false, QStringLiteral("Personnalisez d'abord votre mot de passe.")); return; } if (m_pinDone) { emit pinChangeFinished(true, QStringLiteral("Le code PIN de la YubiKey a déjà été initialisé.")); return; } if (newPin.size() < 4 || confirmation.size() < 4) { emit pinChangeFinished(false, QStringLiteral("Le PIN doit contenir au moins 4 caractères.")); return; } if (newPin != confirmation) { emit pinChangeFinished(false, QStringLiteral("Les deux PIN ne correspondent pas.")); return; } if (containsLineBreak(newPin) || containsLineBreak(confirmation)) { emit pinChangeFinished(false, QStringLiteral("Le PIN contient un caractère non pris en charge.")); return; } startHelper(Operation::Pin, QString::fromUtf8(NIXOS_WORKSTATIONS_PIN_HELPER_PATH), newPin, confirmation); } void ProvisioningBackend::enrollFido(const QString ¤tPassword, const QString &pin) { if (!authorizedUser()) { emit fidoEnrollmentFinished(false, QStringLiteral("Cette opération n'est pas autorisée pour cet utilisateur.")); return; } if (m_busy) { emit fidoEnrollmentFinished(false, QStringLiteral("Une opération de configuration est déjà en cours.")); return; } if (!yubiKeyRequested()) { emit fidoEnrollmentFinished(false, QStringLiteral("Aucune YubiKey n'a été sélectionnée pour ce compte.")); return; } if (!m_passwordDone || !m_pinDone) { emit fidoEnrollmentFinished(false, QStringLiteral("Le mot de passe et le code PIN doivent être configurés avant d'associer la YubiKey.")); return; } if (m_fidoDone) { emit fidoEnrollmentFinished(true, QStringLiteral("La YubiKey est déjà associée à votre espace personnel.")); return; } if (currentPassword.isEmpty() || pin.size() < 4) { emit fidoEnrollmentFinished(false, QStringLiteral("Saisissez le nouveau mot de passe choisi à l’étape 1 et le code PIN de la YubiKey.")); return; } if (containsLineBreak(currentPassword) || containsLineBreak(pin)) { emit fidoEnrollmentFinished(false, QStringLiteral("Un des secrets contient un caractère non pris en charge.")); return; } resetFidoInteraction(); m_fidoStatus = QStringLiteral("Préparation de l'association de la YubiKey…"); emit fidoInteractionChanged(); startHelper(Operation::Fido, QString::fromUtf8(NIXOS_WORKSTATIONS_FIDO_HELPER_PATH), currentPassword, pin); } void ProvisioningBackend::startHelper(Operation operation, const QString &helperPath, const QString &firstSecret, const QString &secondSecret, const QString &thirdSecret) { const QFileInfo helperInfo(helperPath); if (!helperInfo.exists() || !helperInfo.isFile() || !helperInfo.isExecutable()) { appendLog(operationName(operation) + QStringLiteral(" failed: helper unavailable")); emitResult(operation, false, QStringLiteral("Le composant système requis est introuvable ou inutilisable.")); return; } QByteArray first = firstSecret.toUtf8(); QByteArray second = secondSecret.toUtf8(); QByteArray third = thirdSecret.toUtf8(); m_pendingPayload.clear(); m_pendingPayload.reserve(first.size() + second.size() + third.size() + 3); m_pendingPayload += first; m_pendingPayload += '\n'; m_pendingPayload += second; m_pendingPayload += '\n'; m_pendingPayload += third; m_pendingPayload += '\n'; secureClear(first); secureClear(second); secureClear(third); m_helperTimedOut = false; m_helperStdoutBuffer.clear(); setBusy(true); auto *process = new QProcess(this); m_process = process; process->setProgram(helperPath); process->setProcessChannelMode(QProcess::SeparateChannels); QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); env.insert(QStringLiteral("LC_ALL"), QStringLiteral("C")); env.insert(QStringLiteral("LANG"), QStringLiteral("C")); process->setProcessEnvironment(env); auto *timer = new QTimer(process); timer->setSingleShot(true); timer->setInterval(kHelperTimeoutMs); connect(timer, &QTimer::timeout, process, [this, process, operation]() { if (m_process != process || process->state() == QProcess::NotRunning) { return; } m_helperTimedOut = true; appendLog(operationName(operation) + QStringLiteral(" timeout")); process->kill(); }); connect(process, &QProcess::started, this, [this, process, timer, operation]() { if (m_process != process) { return; } process->write(m_pendingPayload); process->closeWriteChannel(); secureClear(m_pendingPayload); appendLog(operationName(operation) + QStringLiteral(" helper started")); timer->start(); }); connect(process, &QProcess::readyReadStandardOutput, this, [this, process, operation]() { if (m_process != process) { return; } handleHelperStdout(operation, process->readAllStandardOutput()); }); connect(process, &QProcess::errorOccurred, this, [this, process, timer, operation](QProcess::ProcessError error) { if (m_process != process || error != QProcess::FailedToStart) { return; } timer->stop(); secureClear(m_pendingPayload); m_process = nullptr; process->deleteLater(); setBusy(false); appendLog(operationName(operation) + QStringLiteral(" failed to start")); if (operation == Operation::Fido) { resetFidoInteraction(); } emitResult(operation, false, QStringLiteral("Impossible de démarrer le composant de configuration.")); }); connect(process, qOverload(&QProcess::finished), this, [this, process, timer, operation](int exitCode, QProcess::ExitStatus exitStatus) { if (m_process != process) { return; } timer->stop(); secureClear(m_pendingPayload); QByteArray stderrData = process->readAllStandardError(); QByteArray stdoutData = process->readAllStandardOutput(); if (operation == Operation::Fido && !stdoutData.isEmpty()) { handleHelperStdout(operation, stdoutData); stdoutData.clear(); } QByteArray combined = stderrData + '\n' + stdoutData; const bool timedOut = m_helperTimedOut; const bool helperSucceeded = !timedOut && exitStatus == QProcess::NormalExit && exitCode == 0; bool markerCreated = false; QString message; if (helperSucceeded) { switch (operation) { case Operation::Password: markerCreated = createMarker(passwordMarker()); message = markerCreated ? QStringLiteral("Votre nouveau mot de passe protège maintenant votre espace personnel.") : QStringLiteral("Le mot de passe a été modifié mais l'état local n'a pas pu être enregistré."); break; case Operation::Pin: markerCreated = createMarker(pinMarker()); message = markerCreated ? QStringLiteral("Le code PIN de votre YubiKey est maintenant initialisé.") : QStringLiteral("Le PIN a été créé mais l'état local n'a pas pu être enregistré."); break; case Operation::Fido: markerCreated = createMarker(fidoMarker()); message = markerCreated ? QStringLiteral("Votre YubiKey peut maintenant ouvrir votre espace personnel chiffré.") : QStringLiteral("La YubiKey a été associée mais l'état local n'a pas pu être enregistré."); break; } } else { message = safeMessageForFailure(operation, exitCode, combined, timedOut); } QString token = QString::fromUtf8(combined).trimmed(); token.remove(QRegularExpression(QStringLiteral("[^A-Za-z0-9_\\-]"))); if (token.size() > 80) token.truncate(80); if (token.isEmpty()) token = QStringLiteral("none"); appendLog(operationName(operation) + QStringLiteral(" helper finished exitCode=%1 timeout=%2 marker=%3 token=%4") .arg(exitCode) .arg(timedOut ? QStringLiteral("yes") : QStringLiteral("no")) .arg(markerCreated ? QStringLiteral("yes") : QStringLiteral("no")) .arg(token)); secureClear(stderrData); secureClear(stdoutData); secureClear(combined); m_process = nullptr; process->deleteLater(); setBusy(false); refreshState(); if (operation == Operation::Fido) { m_fidoTouchRequired = false; m_fidoStatus = helperSucceeded && markerCreated ? QStringLiteral("Association de la YubiKey terminée.") : QStringLiteral("Association de la YubiKey interrompue."); emit fidoInteractionChanged(); } emitResult(operation, helperSucceeded && markerCreated, message); }); process->start(); } QString ProvisioningBackend::safeMessageForFailure(Operation operation, int exitCode, const QByteArray &outputData, bool timedOut) const { const QString output = QString::fromUtf8(outputData).toLower(); if (timedOut || exitCode == 124 || output.contains(QStringLiteral("timeout"))) { if (operation == Operation::Fido) { return QStringLiteral("L'association de la YubiKey a expiré. Laissez-la branchée et recommencez l'opération."); } return QStringLiteral("L'opération a expiré avant de pouvoir être terminée."); } if (operation == Operation::Password) { if (output.contains(QStringLiteral("current_rejected"))) { return QStringLiteral("Le mot de passe temporaire actuel est incorrect."); } if (output.contains(QStringLiteral("new_rejected"))) { return QStringLiteral("Le nouveau mot de passe a été refusé par la politique de sécurité."); } return QStringLiteral("Le mot de passe n'a pas pu être modifié."); } if (operation == Operation::Pin) { if (output.contains(QStringLiteral("pin_already_configured"))) { return QStringLiteral("Cette YubiKey possède déjà un code PIN. Utilisez une YubiKey vierge ou réinitialisée."); } if (output.contains(QStringLiteral("pin_auth_blocked"))) { return QStringLiteral("Les opérations PIN sont temporairement bloquées. Débranchez puis rebranchez la YubiKey."); } if (output.contains(QStringLiteral("pin_blocked"))) { return QStringLiteral("Le code PIN est bloqué. La YubiKey doit être réinitialisée avant de recommencer."); } if (output.contains(QStringLiteral("multiple_yubikey"))) { return QStringLiteral("Plusieurs clés de sécurité sont détectées. Ne laissez branchée que votre YubiKey."); } if (output.contains(QStringLiteral("no_yubikey"))) { return QStringLiteral("Aucune YubiKey compatible n'a été détectée."); } if (output.contains(QStringLiteral("pin_policy"))) { return QStringLiteral("Le code PIN choisi n'est pas accepté par la YubiKey."); } return QStringLiteral("Le code PIN de la YubiKey n'a pas pu être initialisé."); } if (output.contains(QStringLiteral("fido_bad_password"))) { return QStringLiteral("Le mot de passe fourni est incorrect."); } if (output.contains(QStringLiteral("fido_bad_pin"))) { return QStringLiteral("Le code PIN de la YubiKey est incorrect. Vérifiez-le avant une nouvelle tentative."); } if (output.contains(QStringLiteral("multiple_yubikey"))) { return QStringLiteral("Plusieurs clés de sécurité sont détectées. Ne laissez branchée que votre YubiKey."); } if (output.contains(QStringLiteral("no_yubikey"))) { return QStringLiteral("Aucune YubiKey compatible n'a été détectée."); } if (output.contains(QStringLiteral("fido_enroll_failed"))) { return QStringLiteral("La YubiKey n'a pas pu être associée à votre espace personnel. Vérifiez le mot de passe et le code PIN."); } return QStringLiteral("L'association de la YubiKey à votre espace personnel a échoué."); }