343 lines
9.5 KiB
Nix
343 lines
9.5 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
|
|
"""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
|
|
'';
|
|
};
|
|
}
|