{ config, lib, pkgs, ... }: let cfg = config.nixosWorkstations.workstationSetup; passwordHelper = pkgs.writeTextFile { name = "nixos-workstations-password-helper"; executable = true; text = '' #!${pkgs.expect}/bin/expect -f # Les secrets arrivent uniquement par stdin depuis l'application. # Ils ne sont jamais placés dans argv. La sortie du processus passwd # reste masquée : seuls des jetons techniques non sensibles sont renvoyés. log_user 0 exp_internal 0 set timeout 30 proc fail {token code} { puts stderr $token exit $code } if {[gets stdin current] < 0 || [gets stdin newpass] < 0 || [gets stdin confirm] < 0} { fail "INPUT_ERROR" 20 } if {$newpass ne $confirm} { fail "CONFIRM_MISMATCH" 21 } spawn -noecho ${pkgs.coreutils}/bin/env LC_ALL=C LANG=C /run/wrappers/bin/passwd # Étape 1 : authentification du mot de passe actuel. # Selon la pile PAM, l'invite peut être "Current password:", # "(current) UNIX password:" OU simplement "Password:". # Il ne faut donc pas dépendre uniquement des mots Current/Old/UNIX. expect { -re {(?i)(authentication failure|incorrect password|password unchanged|authentication token manipulation error)} { fail "CURRENT_REJECTED" 23 } # Si passwd passe directement au nouveau mot de passe, nous refusons : # l'assistant doit toujours vérifier le mot de passe temporaire actuel. -re {(?i)(new|retype|repeat|confirm)[^\r\n]*(password|passphrase)[^\r\n]*[:?]} { fail "CURRENT_PROMPT_MISSING" 22 } # Invite PAM générique, notamment "Password:". -re {(?i)(password|passphrase)[^\r\n]*[:?]} { send -- "$current\r" } eof { fail "EARLY_EOF_CURRENT" 24 } timeout { fail "TIMEOUT_CURRENT_PROMPT" 124 } } set current "" # Étape 2 : le nouveau mot de passe n'est envoyé qu'après validation # du mot de passe actuel par passwd/PAM. expect { -re {(?i)(authentication failure|incorrect password|password unchanged)} { fail "CURRENT_REJECTED" 25 } -re {(?i)new[^\r\n]*(password|passphrase)[^\r\n]*[:?]} { send -- "$newpass\r" } # Une invite générique "Password:" à ce stade est ambiguë : elle peut # être une nouvelle demande du mot de passe actuel. Par sécurité nous # ne tentons jamais une seconde authentification automatiquement. -re {(?i)(password|passphrase)[^\r\n]*[:?]} { fail "CURRENT_REPROMPT" 25 } eof { fail "EARLY_EOF_NEW" 26 } timeout { fail "TIMEOUT_NEW_PROMPT" 124 } } # Étape 3 : confirmation du nouveau mot de passe. expect { -re {(?i)(bad password|password unchanged|authentication token manipulation error)} { fail "NEW_REJECTED" 27 } -re {(?i)(retype|repeat|confirm)[^\r\n]*(password|passphrase)[^\r\n]*[:?]} { send -- "$confirm\r" } -re {(?i)new[^\r\n]*(password|passphrase)[^\r\n]*[:?]} { fail "NEW_REJECTED" 27 } eof { fail "EARLY_EOF_CONFIRM" 28 } timeout { fail "TIMEOUT_CONFIRM_PROMPT" 124 } } set newpass "" set confirm "" # Étape 4 : passwd doit maintenant terminer. Toute nouvelle invite de # mot de passe signifie que la modification n'a pas été acceptée. expect { eof {} -re {(?i)(password|passphrase)[^\r\n]*[:?]} { fail "UNEXPECTED_PASSWORD_REPROMPT" 29 } timeout { fail "TIMEOUT_FINISH" 124 } } set waitResult [wait] set exitCode [lindex $waitResult 3] if {$exitCode == 0} { puts "OK" exit 0 } fail "PASSWD_FAILED" $exitCode ''; }; pinPython = pkgs.python3.withPackages (ps: [ ps.fido2 ]); pinHelper = pkgs.writeTextFile { name = "nixos-workstations-pin-helper"; executable = true; text = '' #!${pinPython}/bin/python3 """Initialize the PIN of a virgin FIDO2 authenticator. STEP 1 LAB contract: - exactly one FIDO2 HID device must be connected; - the authenticator must support CTAP2; - no FIDO2 PIN must currently be configured; - the new PIN and its confirmation arrive on stdin; - secrets are never placed in argv or emitted in output. Input on stdin, one UTF-8 line each: 1. new PIN 2. confirmation Output contains technical tokens only, never PIN values. """ import sys from fido2.ctap import CtapError from fido2.ctap2 import Ctap2 from fido2.ctap2.pin import ClientPin from fido2.hid import CAPABILITY, CtapHidDevice def fail(token: str, code: int) -> "None": print(token, file=sys.stderr, flush=True) raise SystemExit(code) def read_secret() -> str: value = sys.stdin.readline() if value == "": fail("INPUT_ERROR", 30) return value.rstrip("\r\n") new_pin = read_secret() confirmation = read_secret() if new_pin != confirmation: fail("CONFIRM_MISMATCH", 31) if len(new_pin) < 4 or len(new_pin.encode("utf-8")) > 63: fail("PIN_POLICY", 32) devices = [] try: devices = list(CtapHidDevice.list_devices()) if len(devices) == 0: fail("NO_YUBIKEY", 33) if len(devices) > 1: fail("MULTIPLE_YUBIKEY", 34) device = devices[0] if not (device.capabilities & CAPABILITY.CBOR): fail("NO_FIDO2", 35) ctap = Ctap2(device) info = ctap.get_info() # Étape 1 stricte : on ne doit jamais tenter de valider/changer # un PIN existant. Ce contrôle ne consomme aucune tentative de PIN. if info.options.get("clientPin", False): fail("PIN_ALREADY_CONFIGURED", 36) client_pin = ClientPin(ctap) client_pin.set_pin(new_pin) # GET_INFO ne consomme pas de tentative de PIN. Vérifier que la clé # annonce désormais bien clientPin=True avant de déclarer le succès. if not ctap.get_info().options.get("clientPin", False): fail("PIN_STATE_NOT_UPDATED", 37) new_pin = "" confirmation = "" print("OK", flush=True) raise SystemExit(0) except CtapError as exc: code = exc.code if code == CtapError.ERR.PIN_AUTH_BLOCKED: fail("PIN_AUTH_BLOCKED", 41) if code == CtapError.ERR.PIN_BLOCKED: fail("PIN_BLOCKED", 42) if code == CtapError.ERR.PIN_POLICY_VIOLATION: fail("PIN_POLICY", 44) if code == CtapError.ERR.PIN_NOT_SET: fail("PIN_STATE_ERROR", 45) fail("PIN_FAILED", 46) except (PermissionError, OSError): fail("NO_YUBIKEY", 47) except ValueError: fail("PIN_POLICY", 48) except SystemExit: raise except Exception: fail("PIN_HELPER_ERROR", 49) finally: new_pin = "" confirmation = "" for dev in devices: try: dev.close() except Exception: pass ''; }; workstationSetup = pkgs.stdenv.mkDerivation { pname = "nixos-workstations-setup"; version = "1.6.0"; src = ../workstation-setup; nativeBuildInputs = [ pkgs.cmake pkgs.ninja pkgs.pkg-config pkgs.kdePackages.wrapQtAppsHook ]; buildInputs = with pkgs.kdePackages; [ qtbase qtdeclarative qtwayland kirigami ]; cmakeFlags = [ "-DPASSWORD_HELPER_PATH=${passwordHelper}" "-DPIN_HELPER_PATH=${pinHelper}" ]; }; launcher = pkgs.writeShellScript "nixos-workstations-setup-launcher" '' set -eu current_user="$(${pkgs.coreutils}/bin/id -un)" target_user=${lib.escapeShellArg cfg.user} # L'autostart ne doit concerner que l'utilisateur configuré. if [ "$current_user" != "$target_user" ]; then exit 0 fi state_dir="''${XDG_STATE_HOME:-$HOME/.local/state}/nixos-workstations" password_marker="$state_dir/password-initialized" pin_marker="$state_dir/yubikey-pin-created" # Une session déjà finalisée n'affiche plus l'assistant. if [ -e "$password_marker" ] && [ -e "$pin_marker" ]; then exit 0 fi # Ne jamais générer de core dump contenant potentiellement un secret. ulimit -c 0 # Laisse Plasma terminer son démarrage. ${pkgs.coreutils}/bin/sleep 3 exec ${workstationSetup}/bin/nixos-workstations-setup \ --target-user "$target_user" ''; in { options.nixosWorkstations.workstationSetup = { enable = lib.mkEnableOption "assistant plein écran de finalisation du poste"; user = lib.mkOption { type = lib.types.str; default = "alice"; description = "Utilisateur devant effectuer la personnalisation initiale."; }; }; config = lib.mkIf cfg.enable { environment.systemPackages = [ workstationSetup ]; environment.etc."xdg/autostart/nixos-workstations-setup.desktop".text = '' [Desktop Entry] Type=Application Name=Finalisation du poste Comment=Personnalisation sécurisée des moyens d'authentification Exec=${launcher} OnlyShowIn=KDE; NoDisplay=true StartupNotify=false ''; }; }