diff --git a/modules/workstation-setup.nix b/modules/workstation-setup.nix index ecdd8d8..69d2331 100644 --- a/modules/workstation-setup.nix +++ b/modules/workstation-setup.nix @@ -127,124 +127,144 @@ let ''; }; + pinPython = pkgs.python3.withPackages (ps: [ + ps.fido2 + ]); + pinHelper = pkgs.writeTextFile { name = "nixos-workstations-pin-helper"; executable = true; text = '' - #!${pkgs.expect}/bin/expect -f + #!${pinPython}/bin/python3 + """Change a FIDO2 PIN without exposing secrets in argv. - # Une seule invocation de ykman par clic : aucune tentative automatique - # supplémentaire n'est faite en cas de mauvais PIN. - log_user 0 - exp_internal 0 - set timeout 30 + Input on stdin, one UTF-8 line each: + 1. current PIN + 2. new PIN + 3. confirmation - proc fail {token code} { - puts stderr $token - exit $code - } + Output contains technical tokens only, never the PIN values. + Exactly one CTAP2 change_pin operation is attempted. + """ - if {[gets stdin current] < 0 || [gets stdin newpin] < 0 || [gets stdin confirm] < 0} { - fail "INPUT_ERROR" 30 - } + import sys - if {$newpin ne $confirm} { - fail "CONFIRM_MISMATCH" 31 - } + from fido2.ctap import CtapError + from fido2.ctap2 import Ctap2 + from fido2.ctap2.pin import ClientPin + from fido2.hid import CAPABILITY, CtapHidDevice - spawn -noecho ${pkgs.coreutils}/bin/env LC_ALL=C LANG=C ${pkgs.yubikey-manager}/bin/ykman fido access change-pin - set transcript "" + def fail(token: str, code: int) -> "None": + print(token, file=sys.stderr, flush=True) + raise SystemExit(code) - # Une clé déjà provisionnée doit demander le PIN actuel. Si ykman passe - # directement au nouveau PIN, la clé n'a pas le pré-provisionnement attendu. - expect { - -re {(?i)enter.*current.*pin.*:} { - append transcript $expect_out(buffer) - send -- "$current\r" - } - -re {(?i)enter.*new.*pin.*:} { - append transcript $expect_out(buffer) - fail "PIN_NOT_CONFIGURED" 32 - } - -re {(?i)(no yubikey|multiple yubikey|failed to connect|no fido|device.*not found|permission denied|access denied)} { - append transcript $expect_out(buffer) - fail "NO_YUBIKEY" 33 - } - eof { - append transcript $expect_out(buffer) - if {[regexp -nocase {no yubikey|multiple yubikey|failed to connect|no fido|device.*not found|permission denied|access denied} $transcript]} { - fail "NO_YUBIKEY" 34 - } - fail "PIN_FAILED" 35 - } - timeout { - fail "TIMEOUT_CURRENT_PIN" 124 - } - } - set current "" + def read_secret() -> str: + value = sys.stdin.readline() + if value == "": + fail("INPUT_ERROR", 30) + return value.rstrip("\r\n") - expect { - -re {(?i)enter.*new.*pin.*:} { - append transcript $expect_out(buffer) - send -- "$newpin\r" - } - eof { - append transcript $expect_out(buffer) - fail "PIN_FAILED" 36 - } - timeout { - fail "TIMEOUT_NEW_PIN" 124 - } - } - expect { - -re {(?i)(repeat|confirm).*:} { - append transcript $expect_out(buffer) - send -- "$confirm\r" - } - eof { - append transcript $expect_out(buffer) - fail "PIN_FAILED" 37 - } - timeout { - fail "TIMEOUT_CONFIRM_PIN" 124 - } - } + current = read_secret() + new_pin = read_secret() + confirmation = read_secret() - set newpin "" - set confirm "" + if new_pin != confirmation: + fail("CONFIRM_MISMATCH", 31) - expect eof - append transcript $expect_out(buffer) - set waitResult [wait] - set exitCode [lindex $waitResult 3] + if len(current) < 4 or len(new_pin) < 4: + fail("PIN_POLICY", 32) - if {$exitCode == 0} { - puts "OK" - exit 0 - } + # 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) - if {[regexp -nocase {pin[_ ]auth[_ ]blocked|ctaperr_pin_auth_blocked|temporarily blocked} $transcript]} { - fail "PIN_AUTH_BLOCKED" $exitCode - } elseif {[regexp -nocase {pin[_ ]blocked|ctaperr_pin_blocked|pin is blocked} $transcript]} { - fail "PIN_BLOCKED" $exitCode - } elseif {[regexp -nocase {wrong pin|pin_invalid|pin auth invalid|pin verification failed|ctaperr_pin_invalid} $transcript]} { - fail "WRONG_PIN" $exitCode - } elseif {[regexp -nocase {no yubikey|multiple yubikey|failed to connect|no fido|device.*not found|permission denied|access denied} $transcript]} { - fail "NO_YUBIKEY" $exitCode - } elseif {[regexp -nocase {complexity|policy|minimum pin length|must be at least|too short} $transcript]} { - fail "PIN_POLICY" $exitCode - } + devices = [] + device = None - fail "PIN_FAILED" $exitCode + 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.4.0"; + version = "1.5.0"; src = ../workstation-setup;