{ 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 """Change a FIDO2 PIN without exposing secrets in argv. Input on stdin, one UTF-8 line each: 1. current PIN 2. new PIN 3. confirmation Output contains technical tokens only, never the PIN values. Exactly one CTAP2 change_pin operation is attempted. """ 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") current = read_secret() new_pin = read_secret() confirmation = read_secret() if new_pin != confirmation: fail("CONFIRM_MISMATCH", 31) if len(current) < 4 or len(new_pin) < 4: fail("PIN_POLICY", 32) # CTAP2 PINs are UTF-8 strings with a maximum encoded size of 63 bytes. if len(current.encode("utf-8")) > 63 or len(new_pin.encode("utf-8")) > 63: fail("PIN_POLICY", 32) devices = [] device = None 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() # clientPin=True means a PIN is currently configured. if not info.options.get("clientPin", False): fail("PIN_NOT_CONFIGURED", 36) client_pin = ClientPin(ctap) # IMPORTANT: exactly one PIN-changing command. Do not verify the old PIN # first: that would add an unnecessary extra PIN operation. client_pin.change_pin(current, new_pin) # Forget Python references as soon as the operation is complete. current = "" new_pin = "" confirmation = "" print("OK", flush=True) raise SystemExit(0) except CtapError as exc: code = exc.code if code == CtapError.ERR.PIN_INVALID: fail("WRONG_PIN", 40) 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_NOT_SET: fail("PIN_NOT_CONFIGURED", 43) if code == CtapError.ERR.PIN_POLICY_VIOLATION: fail("PIN_POLICY", 44) fail("PIN_FAILED", 45) except (PermissionError, OSError): fail("NO_YUBIKEY", 46) except ValueError: # python-fido2 may reject an invalid PIN before sending CTAP2. fail("PIN_POLICY", 47) except SystemExit: raise except Exception: # Do not expose exception text: it could contain device/environment # details and is not needed by the UI. The setup log stores the token. fail("PIN_HELPER_ERROR", 48) finally: current = "" new_pin = "" confirmation = "" for dev in devices: try: dev.close() except Exception: pass ''; }; workstationSetup = pkgs.stdenv.mkDerivation { pname = "nixos-workstations-setup"; version = "1.5.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-initialized" # 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 ''; }; }