Files
nixos-workstations/modules/workstation-setup.nix
T

350 lines
9.8 KiB
Nix

{ 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
'';
};
}