diff --git a/modules/homed.nix b/modules/homed.nix index d43b666..edc4ec9 100644 --- a/modules/homed.nix +++ b/modules/homed.nix @@ -1,73 +1,131 @@ -{ config, ... }: +{ config, lib, pkgs, ... }: +let + cfg = config.nixosWorkstations.homedUser; + + # LAB : le mot de passe initial est volontairement un mot de passe par défaut + # connu. Il ne constitue pas un secret durable : Alice doit le remplacer lors + # de sa première session. Pour une future version production, ce credential + # devra venir d'une source chiffrée/externe et non du Nix store. + initialPasswordCredential = pkgs.writeText + "nixos-workstations-${cfg.user}-initial-password" + cfg.initialPassword; + + provisionService = "nixos-workstations-provision-${cfg.user}"; +in { - # - # systemd-homed - # - # Les utilisateurs finaux sont gérés par systemd-homed et non par - # users.users.. - # - # Leur home pourra être stocké dans un volume LUKS2 individuel. - # - services.homed.enable = true; + options.nixosWorkstations.homedUser = { + enable = lib.mkEnableOption "précréation d'un utilisateur systemd-homed chiffré"; - - # - # NixOS possède des utilisateurs système nixbld avec des UID > 1000. - # - # Cela déclenche un avertissement de systemd-userdb concernant - # l'existence d'utilisateurs "réguliers". - # - # Dans notre architecture ce warning n'est pas pertinent : - # les utilisateurs homed sont créés explicitement avec homectl. - # - services.userdbd.silenceHighSystemUsers = true; - - - # - # SDDM + systemd-homed - # - # systemd-homed utilise des UID dynamiques élevés. - # La plage réservée aux utilisateurs homed monte jusqu'à 60513. - # - # NixOS configure normalement SDDM avec MaximumUid = nixbld, - # soit environ 30000, ce qui exclurait Alice (UID 60456 dans notre test). - # - services.displayManager.sddm.settings = { - - Users = { - MinimumUid = 1000; - MaximumUid = 60513; + user = lib.mkOption { + type = lib.types.str; + default = "alice"; + description = "Compte systemd-homed à préparer automatiquement."; }; + realName = lib.mkOption { + type = lib.types.str; + default = "Alice"; + description = "Nom complet de l'utilisateur systemd-homed."; + }; - # - # IMPORTANT : - # - # SDDM construit sa liste d'utilisateurs dans sddm-greeter avec - # getpwent(). - # - # Sous NixOS, libnss_systemd.so n'est pas dans le chemin standard - # du linker. Le chemin des modules NSS est fourni par : - # - # config.system.nssModules.path - # - # Notre test a confirmé que : - # - # LD_LIBRARY_PATH= - # getent -s systemd passwd - # - # permet immédiatement d'énumérer Alice. - # - # GreeterEnvironment est le mécanisme SDDM prévu pour transmettre - # ces variables au processus sddm-greeter. - # - # On conserve également la variable nécessaire au greeter - # KDE/Wayland de NixOS. - # - General = { - GreeterEnvironment = - "QT_WAYLAND_SHELL_INTEGRATION=layer-shell,LD_LIBRARY_PATH=${config.system.nssModules.path}"; + initialPassword = lib.mkOption { + type = lib.types.str; + default = "LaboTest@1980"; + description = "Mot de passe temporaire utilisé uniquement pour le premier accès LAB."; + }; + + diskSize = lib.mkOption { + type = lib.types.str; + default = "10G"; + description = "Taille initiale du conteneur LUKS2 du home."; }; }; -} \ No newline at end of file + + config = lib.mkIf cfg.enable { + services.homed.enable = true; + + # Les comptes nixbld NixOS ont des UID > 1000. Nous n'utilisons pas le + # workflow first-boot de homed : les comptes sont créés explicitement par + # le service ci-dessous. + services.userdbd.silenceHighSystemUsers = true; + + # SDDM doit accepter la plage d'UID systemd-homed et son greeter doit + # charger libnss_systemd directement. Cette configuration a été validée en + # LAB : sans ce LD_LIBRARY_PATH, getpwent() n'énumère pas Alice sous NixOS. + services.displayManager.sddm.settings = { + Users = { + MinimumUid = 1000; + MaximumUid = 60513; + }; + + General = { + GreeterEnvironment = + "QT_WAYLAND_SHELL_INTEGRATION=layer-shell,LD_LIBRARY_PATH=${config.system.nssModules.path}"; + }; + }; + + # Précréation automatique d'Alice. localadm ne lance qu'un + # nixos-rebuild switch : ce service est démarré automatiquement et ne fait + # rien si le compte homed existe déjà. + # + # RemainAfterExit est volontairement conservé : SDDM dépend de la réussite + # de ce service au démarrage. Le script lui-même reste idempotent. + systemd.services.${provisionService} = { + description = "Prépare le compte systemd-homed ${cfg.user}"; + wantedBy = [ "multi-user.target" ]; + before = [ "display-manager.service" ]; + after = [ "systemd-homed.service" ]; + requires = [ "systemd-homed.service" ]; + + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + LoadCredential = [ + "home.new-password:${initialPasswordCredential}" + ]; + }; + + path = [ + pkgs.coreutils + pkgs.getent + pkgs.systemd + ]; + + script = '' + set -eu + + user=${lib.escapeShellArg cfg.user} + + if ${pkgs.systemd}/bin/homectl inspect "$user" >/dev/null 2>&1; then + echo "Compte homed $user déjà présent : aucune modification." + exit 0 + fi + + # Refuser une collision avec un compte UNIX classique du même nom. + if ${pkgs.getent}/bin/getent passwd "$user" >/dev/null 2>&1; then + echo "ERREUR : $user existe déjà mais n'est pas un compte systemd-homed." >&2 + exit 1 + fi + + echo "Création du compte systemd-homed $user et de son home LUKS2..." + + ${pkgs.systemd}/bin/homectl --no-ask-password create "$user" \ + --real-name=${lib.escapeShellArg cfg.realName} \ + --storage=luks \ + --fs-type=ext4 \ + --disk-size=${lib.escapeShellArg cfg.diskSize} \ + --access-mode=0700 \ + --shell=/run/current-system/sw/bin/bash \ + --password-change-now=no \ + --recovery-key=no + ''; + }; + + # Au boot, le compte doit exister avant le lancement du greeter SDDM. + systemd.services.display-manager = { + after = [ "${provisionService}.service" ]; + requires = [ "${provisionService}.service" ]; + }; + }; +} diff --git a/modules/security.nix b/modules/security.nix index d463f5a..07e355b 100644 --- a/modules/security.nix +++ b/modules/security.nix @@ -1,11 +1,11 @@ { ... }: { - # Étape 1 LAB : la YubiKey est uniquement initialisée par le setup. - # Aucune authentification PAM/FIDO2 n'est activée à ce stade et aucun - # credential spécifique à une clé n'est conservé dans Git. + # La YubiKey est utilisée directement par python-fido2 puis par + # systemd-homed. Aucun pam_u2f et aucun fichier u2f-mappings ne sont + # nécessaires dans cette architecture. # - # YubiKey Manager apporte notamment les règles udev permettant l'accès - # utilisateur à la clé. L'enrôlement systemd-homed viendra à l'étape 3. + # YubiKey Manager fournit également les règles udev nécessaires à l'accès + # utilisateur au périphérique FIDO2. programs.yubikey-manager.enable = true; } diff --git a/modules/users.nix b/modules/users.nix index a302bd3..2b867b4 100644 --- a/modules/users.nix +++ b/modules/users.nix @@ -1,13 +1,11 @@ { pkgs, ... }: { - # Les comptes UNIX classiques restent mutables. - # Alice n'est volontairement PLUS déclarée ici : elle sera créée - # interactivement par systemd-homed avec `homectl create alice`. users.mutableUsers = true; - # Compte d'administration local classique, conservé comme compte de - # secours pendant tous les essais systemd-homed. + # Alice n'est PAS déclarée dans users.users : elle appartient exclusivement + # à systemd-homed et est créée automatiquement par modules/homed.nix. + users.users.localadm = { isNormalUser = true; description = "Administrateur local"; diff --git a/modules/workstation-setup.nix b/modules/workstation-setup.nix index 097926e..0e71508 100644 --- a/modules/workstation-setup.nix +++ b/modules/workstation-setup.nix @@ -3,155 +3,84 @@ let cfg = config.nixosWorkstations.workstationSetup; - passwordHelper = pkgs.writeTextFile { - name = "nixos-workstations-password-helper"; - executable = true; - text = '' - #!${pkgs.expect}/bin/expect -f + passwordHelper = pkgs.writeShellScript "nixos-workstations-homed-password-helper" '' + set -eu + umask 077 - # 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 + fail() { + printf '%s\n' "$1" >&2 + exit "$2" + } - proc fail {token code} { - puts stderr $token - exit $code - } + IFS= read -r current || fail INPUT_ERROR 20 + IFS= read -r newpass || fail INPUT_ERROR 20 + IFS= read -r confirm || fail INPUT_ERROR 20 - if {[gets stdin current] < 0 || [gets stdin newpass] < 0 || [gets stdin confirm] < 0} { - fail "INPUT_ERROR" 20 - } + [ "$newpass" = "$confirm" ] || fail CONFIRM_MISMATCH 21 + [ -n "$current" ] && [ -n "$newpass" ] || fail INPUT_ERROR 20 - if {$newpass ne $confirm} { - fail "CONFIRM_MISMATCH" 21 - } + runtime_dir="''${XDG_RUNTIME_DIR:-/run/user/$(${pkgs.coreutils}/bin/id -u)}" + cred_dir="$(${pkgs.coreutils}/bin/mktemp -d "$runtime_dir/nixos-workstations-passwd.XXXXXX")" + log_file="$cred_dir/homectl.log" - spawn -noecho ${pkgs.coreutils}/bin/env LC_ALL=C LANG=C /run/wrappers/bin/passwd + cleanup() { + current='' + newpass='' + confirm='' + ${pkgs.coreutils}/bin/rm -f \ + "$cred_dir/home.password" \ + "$cred_dir/home.new-password" \ + "$log_file" 2>/dev/null || true + ${pkgs.coreutils}/bin/rmdir "$cred_dir" 2>/dev/null || true + } + trap cleanup EXIT HUP INT TERM - # É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 - } - } + ${pkgs.coreutils}/bin/printf '%s' "$current" > "$cred_dir/home.password" + ${pkgs.coreutils}/bin/printf '%s' "$newpass" > "$cred_dir/home.new-password" + ${pkgs.coreutils}/bin/chmod 600 "$cred_dir/home.password" "$cred_dir/home.new-password" - set current "" + user="$(${pkgs.coreutils}/bin/id -un)" - # É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 - } - } + current='' + newpass='' + confirm='' - # É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 +e + CREDENTIALS_DIRECTORY="$cred_dir" \ + LC_ALL=C LANG=C \ + ${pkgs.coreutils}/bin/timeout 60 \ + ${pkgs.systemd}/bin/homectl --no-ask-password --no-pager passwd "$user" \ + >"$log_file" 2>&1 + rc=$? + set -e - set newpass "" - set confirm "" + if [ "$rc" -eq 0 ]; then + printf '%s\n' OK + exit 0 + fi - # É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 - } - } + if [ "$rc" -eq 124 ]; then + fail TIMEOUT_PASSWORD 124 + fi - set waitResult [wait] - set exitCode [lindex $waitResult 3] + if ${pkgs.gnugrep}/bin/grep -Eqi 'password incorrect|not sufficient|bad password' "$log_file"; then + fail CURRENT_REJECTED 22 + fi - if {$exitCode == 0} { - puts "OK" - exit 0 - } + if ${pkgs.gnugrep}/bin/grep -Eqi 'quality|too short|weak|dictionary' "$log_file"; then + fail NEW_REJECTED 23 + fi - fail "PASSWD_FAILED" $exitCode - ''; - }; + fail HOMECTL_PASSWD_FAILED 24 + ''; - pinPython = pkgs.python3.withPackages (ps: [ - ps.fido2 - ]); + 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 @@ -159,18 +88,15 @@ let 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") - + return value.rstrip("\\r\\n") new_pin = read_secret() confirmation = read_secret() @@ -182,34 +108,23 @@ let 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): + if ctap.get_info().options.get("clientPin", False): fail("PIN_ALREADY_CONFIGURED", 36) - client_pin = ClientPin(ctap) - client_pin.set_pin(new_pin) + ClientPin(ctap).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) @@ -220,30 +135,21 @@ let 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 = "" @@ -255,9 +161,88 @@ let ''; }; + fidoEnrollHelper = pkgs.writeShellScript "nixos-workstations-homed-fido-helper" '' + set -eu + umask 077 + + fail() { + printf '%s\n' "$1" >&2 + exit "$2" + } + + IFS= read -r current_password || fail INPUT_ERROR 50 + IFS= read -r token_pin || fail INPUT_ERROR 50 + + [ -n "$current_password" ] && [ -n "$token_pin" ] || fail INPUT_ERROR 50 + + runtime_dir="''${XDG_RUNTIME_DIR:-/run/user/$(${pkgs.coreutils}/bin/id -u)}" + cred_dir="$(${pkgs.coreutils}/bin/mktemp -d "$runtime_dir/nixos-workstations-fido.XXXXXX")" + log_file="$cred_dir/homectl.log" + + cleanup() { + current_password='' + token_pin='' + ${pkgs.coreutils}/bin/rm -f \ + "$cred_dir/home.password" \ + "$cred_dir/home.token-pin" \ + "$log_file" 2>/dev/null || true + ${pkgs.coreutils}/bin/rmdir "$cred_dir" 2>/dev/null || true + } + trap cleanup EXIT HUP INT TERM + + ${pkgs.coreutils}/bin/printf '%s' "$current_password" > "$cred_dir/home.password" + ${pkgs.coreutils}/bin/printf '%s' "$token_pin" > "$cred_dir/home.token-pin" + ${pkgs.coreutils}/bin/chmod 600 "$cred_dir/home.password" "$cred_dir/home.token-pin" + + user="$(${pkgs.coreutils}/bin/id -un)" + + current_password='' + token_pin='' + + set +e + CREDENTIALS_DIRECTORY="$cred_dir" \ + LC_ALL=C LANG=C \ + ${pkgs.coreutils}/bin/timeout 120 \ + ${pkgs.systemd}/bin/homectl --no-ask-password --no-pager update "$user" \ + --fido2-device=auto \ + --fido2-with-client-pin=yes \ + --fido2-with-user-presence=yes \ + --fido2-with-user-verification=no \ + >"$log_file" 2>&1 + rc=$? + set -e + + if [ "$rc" -eq 0 ]; then + printf '%s\n' OK + exit 0 + fi + + if [ "$rc" -eq 124 ]; then + fail TIMEOUT_FIDO 124 + fi + + if ${pkgs.gnugrep}/bin/grep -Eqi 'PIN.*incorrect|Bad PIN|bad pin' "$log_file"; then + fail FIDO_BAD_PIN 51 + fi + + if ${pkgs.gnugrep}/bin/grep -Eqi 'password.*incorrect|not sufficient|BadPassword' "$log_file"; then + fail FIDO_BAD_PASSWORD 52 + fi + + if ${pkgs.gnugrep}/bin/grep -Eqi 'multiple|more than one.*FIDO|auto.*device' "$log_file"; then + fail MULTIPLE_YUBIKEY 53 + fi + + if ${pkgs.gnugrep}/bin/grep -Eqi 'no.*FIDO|No such device|not found|not inserted' "$log_file"; then + fail NO_YUBIKEY 54 + fi + + fail FIDO_ENROLL_FAILED 55 + ''; + workstationSetup = pkgs.stdenv.mkDerivation { pname = "nixos-workstations-setup"; - version = "1.6.0"; + version = "1.8.0"; src = ../workstation-setup; @@ -278,6 +263,7 @@ let cmakeFlags = [ "-DPASSWORD_HELPER_PATH=${passwordHelper}" "-DPIN_HELPER_PATH=${pinHelper}" + "-DFIDO_HELPER_PATH=${fidoEnrollHelper}" ]; }; @@ -287,24 +273,18 @@ let 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 + [ "$current_user" = "$target_user" ] || exit 0 state_dir="''${XDG_STATE_HOME:-$HOME/.local/state}/nixos-workstations" password_marker="$state_dir/password-initialized" pin_marker="$state_dir/yubikey-pin-created" + fido_marker="$state_dir/yubikey-fido-enrolled" - # Une session déjà finalisée n'affiche plus l'assistant. - if [ -e "$password_marker" ] && [ -e "$pin_marker" ]; then + if [ -e "$password_marker" ] && [ -e "$pin_marker" ] && [ -e "$fido_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 \ @@ -314,18 +294,19 @@ let in { options.nixosWorkstations.workstationSetup = { - enable = lib.mkEnableOption "assistant plein écran de finalisation du poste"; + enable = lib.mkEnableOption "assistant de première session"; user = lib.mkOption { type = lib.types.str; default = "alice"; - description = "Utilisateur devant effectuer la personnalisation initiale."; + description = "Utilisateur devant finaliser son authentification."; }; }; config = lib.mkIf cfg.enable { environment.systemPackages = [ workstationSetup + pkgs.systemd ]; environment.etc."xdg/autostart/nixos-workstations-setup.desktop".text = ''