From 37ec7c488a3579a868014f53b87f669bf65ed83f Mon Sep 17 00:00:00 2001 From: Sean Buckley Date: Sun, 21 Jul 2019 16:39:07 +0000 Subject: [PATCH 1/3] grub: add support for passwords This patch adds support for user accounts/passwords in GRUB 2. When configured, everything but the default option is password-protected. --- .../modules/system/boot/loader/grub/grub.nix | 62 +++++++++++++++++++ .../system/boot/loader/grub/install-grub.pl | 45 +++++++++++++- 2 files changed, 104 insertions(+), 3 deletions(-) diff --git a/nixos/modules/system/boot/loader/grub/grub.nix b/nixos/modules/system/boot/loader/grub/grub.nix index 3975372e15ec..b760c3f96ddf 100644 --- a/nixos/modules/system/boot/loader/grub/grub.nix +++ b/nixos/modules/system/boot/loader/grub/grub.nix @@ -55,6 +55,7 @@ let storePath = config.boot.loader.grub.storePath; bootloaderId = if args.efiBootloaderId == null then "NixOS${efiSysMountPoint'}" else args.efiBootloaderId; timeout = if config.boot.loader.timeout == null then -1 else config.boot.loader.timeout; + users = if cfg.users == {} || cfg.version != 1 then cfg.users else throw "GRUB version 1 does not support user accounts."; inherit efiSysMountPoint; inherit (args) devices; inherit (efi) canTouchEfiVariables; @@ -137,6 +138,67 @@ in ''; }; + users = mkOption { + default = {}; + example = { + root = { hashedPasswordFile = "/path/to/file"; }; + }; + description = '' + User accounts for GRUB. When specified, the GRUB command line and + all boot options except the default are password-protected. + All passwords and hashes provided will be stored in /boot/grub/grub.cfg, + and will be visible to any local user who can read this file. Additionally, + any passwords and hashes provided directly in a Nix configuration + (as opposed to external files) will be copied into the Nix store, and + will be visible to all local users. + ''; + type = with types; attrsOf (submodule { + options = { + hashedPasswordFile = mkOption { + example = "/path/to/file"; + default = null; + type = with types; uniq (nullOr str); + description = '' + Specifies the path to a file containing the password hash + for the account, generated with grub-mkpasswd-pbkdf2. + This hash will be stored in /boot/grub/grub.cfg, and will + be visible to any local user who can read this file. + ''; + }; + hashedPassword = mkOption { + example = "grub.pbkdf2.sha512.10000.674DFFDEF76E13EA...2CC972B102CF4355"; + default = null; + type = with types; uniq (nullOr str); + description = '' + Specifies the password hash for the account, + generated with grub-mkpasswd-pbkdf2. + This hash will be copied to the Nix store, and will be visible to all local users. + ''; + }; + passwordFile = mkOption { + example = "/path/to/file"; + default = null; + type = with types; uniq (nullOr str); + description = '' + Specifies the path to a file containing the + clear text password for the account. + This password will be stored in /boot/grub/grub.cfg, and will + be visible to any local user who can read this file. + ''; + }; + password = mkOption { + example = "Pa$$w0rd!"; + default = null; + type = with types; uniq (nullOr str); + description = '' + Specifies the clear text password for the account. + This password will be copied to the Nix store, and will be visible to all local users. + ''; + }; + }; + }); + }; + mirroredBoots = mkOption { default = [ ]; example = [ diff --git a/nixos/modules/system/boot/loader/grub/install-grub.pl b/nixos/modules/system/boot/loader/grub/install-grub.pl index e469b18abd09..918a66866e96 100644 --- a/nixos/modules/system/boot/loader/grub/install-grub.pl +++ b/nixos/modules/system/boot/loader/grub/install-grub.pl @@ -247,6 +247,45 @@ if ($grubVersion == 1) { } else { + my @users = (); + foreach my $user ($dom->findnodes('/expr/attrs/attr[@name = "users"]/attrs/attr')) { + my $name = $user->findvalue('@name') or die; + my $hashedPassword = $user->findvalue('./attrs/attr[@name = "hashedPassword"]/string/@value'); + my $hashedPasswordFile = $user->findvalue('./attrs/attr[@name = "hashedPasswordFile"]/string/@value'); + my $password = $user->findvalue('./attrs/attr[@name = "password"]/string/@value'); + my $passwordFile = $user->findvalue('./attrs/attr[@name = "passwordFile"]/string/@value'); + + if ($hashedPasswordFile) { + open(my $f, '<', $hashedPasswordFile) or die "Can't read file '$hashedPasswordFile'!"; + $hashedPassword = <$f>; + chomp $hashedPassword; + } + if ($passwordFile) { + open(my $f, '<', $passwordFile) or die "Can't read file '$passwordFile'!"; + $password = <$f>; + chomp $password; + } + + if ($hashedPassword) { + if (index($hashedPassword, "grub.pbkdf2.") == 0) { + $conf .= "\npassword_pbkdf2 $name $hashedPassword"; + } + else { + die "Password hash for GRUB user '$name' is not valid!"; + } + } + elsif ($password) { + $conf .= "\npassword $name $password"; + } + else { + die "GRUB user '$name' has no password!"; + } + push(@users, $name); + } + if (@users) { + $conf .= "\nset superusers=\"" . join(' ',@users) . "\"\n"; + } + if ($copyKernels == 0) { $conf .= " " . $grubStore->search; @@ -350,7 +389,7 @@ sub copyToKernelsDir { } sub addEntry { - my ($name, $path) = @_; + my ($name, $path, $options) = @_; return unless -e "$path/kernel" && -e "$path/initrd"; my $kernel = copyToKernelsDir(Cwd::abs_path("$path/kernel")); @@ -396,7 +435,7 @@ sub addEntry { $conf .= " " . ($xen ? "module" : "kernel") . " $kernel $kernelParams\n"; $conf .= " " . ($xen ? "module" : "initrd") . " $initrd\n\n"; } else { - $conf .= "menuentry \"$name\" {\n"; + $conf .= "menuentry \"$name\" " . ($options||"") . " {\n"; $conf .= $grubBoot->search . "\n"; if ($copyKernels == 0) { $conf .= $grubStore->search . "\n"; @@ -413,7 +452,7 @@ sub addEntry { # Add default entries. $conf .= "$extraEntries\n" if $extraEntriesBeforeNixOS; -addEntry("NixOS - Default", $defaultConfig); +addEntry("NixOS - Default", $defaultConfig, "--unrestricted"); $conf .= "$extraEntries\n" unless $extraEntriesBeforeNixOS; From b520055df6c9913ef4ddbc931a1635516433a7a9 Mon Sep 17 00:00:00 2001 From: rnhmjoj Date: Sat, 13 Jun 2020 12:04:05 +0200 Subject: [PATCH 2/3] nixos/lib/test-driver: add wait_for_console_text This method is similar to wait_for_text but is based on matching serial console lines instead of the VGA output. --- .../development/writing-nixos-tests.xml | 12 ++++++++++ nixos/lib/test-driver/test-driver.py | 23 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/nixos/doc/manual/development/writing-nixos-tests.xml b/nixos/doc/manual/development/writing-nixos-tests.xml index 150bea8c2d86..ff37b3b2f6f3 100644 --- a/nixos/doc/manual/development/writing-nixos-tests.xml +++ b/nixos/doc/manual/development/writing-nixos-tests.xml @@ -360,6 +360,18 @@ start_all() + + + wait_for_console_text + + + + Wait until the supplied regular expressions match a line of the serial + console output. This method is useful when OCR is not possibile or + accurate enough. + + + wait_for_window diff --git a/nixos/lib/test-driver/test-driver.py b/nixos/lib/test-driver/test-driver.py index e7b05968b079..0c1946387ae9 100644 --- a/nixos/lib/test-driver/test-driver.py +++ b/nixos/lib/test-driver/test-driver.py @@ -3,6 +3,8 @@ from contextlib import contextmanager, _GeneratorContextManager from queue import Queue, Empty from typing import Tuple, Any, Callable, Dict, Iterator, Optional, List from xml.sax.saxutils import XMLGenerator +import queue +import io import _thread import atexit import base64 @@ -671,6 +673,22 @@ class Machine: with self.nested("waiting for {} to appear on screen".format(regex)): retry(screen_matches) + def wait_for_console_text(self, regex: str) -> None: + self.log("waiting for {} to appear on console".format(regex)) + # Buffer the console output, this is needed + # to match multiline regexes. + console = io.StringIO() + while True: + try: + console.write(self.last_lines.get()) + except queue.Empty: + self.sleep(1) + continue + console.seek(0) + matches = re.search(regex, console.read()) + if matches is not None: + return + def send_key(self, key: str) -> None: key = CHAR_TO_KEY.get(key, key) self.send_monitor_command("sendkey {}".format(key)) @@ -734,11 +752,16 @@ class Machine: self.monitor, _ = self.monitor_socket.accept() self.shell, _ = self.shell_socket.accept() + # Store last serial console lines for use + # of wait_for_console_text + self.last_lines: Queue = Queue() + def process_serial_output() -> None: assert self.process.stdout is not None for _line in self.process.stdout: # Ignore undecodable bytes that may occur in boot menus line = _line.decode(errors="ignore").replace("\r", "").rstrip() + self.last_lines.put(line) eprint("{} # {}".format(self.name, line)) self.logger.enqueue({"msg": line, "machine": self.name}) From 33c4a4bdd52a750205046cc85775e6caea342868 Mon Sep 17 00:00:00 2001 From: rnhmjoj Date: Sat, 13 Jun 2020 12:06:26 +0200 Subject: [PATCH 3/3] nixos/tests: add test for grub authentication --- nixos/tests/all-tests.nix | 1 + nixos/tests/grub.nix | 60 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 nixos/tests/grub.nix diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 7056d414e9e9..5ac7e73977bb 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -124,6 +124,7 @@ in grafana = handleTest ./grafana.nix {}; graphite = handleTest ./graphite.nix {}; graylog = handleTest ./graylog.nix {}; + grub = handleTest ./grub.nix {}; gvisor = handleTest ./gvisor.nix {}; hadoop.hdfs = handleTestOn [ "x86_64-linux" ] ./hadoop/hdfs.nix {}; hadoop.yarn = handleTestOn [ "x86_64-linux" ] ./hadoop/yarn.nix {}; diff --git a/nixos/tests/grub.nix b/nixos/tests/grub.nix new file mode 100644 index 000000000000..84bfc90955b5 --- /dev/null +++ b/nixos/tests/grub.nix @@ -0,0 +1,60 @@ +import ./make-test-python.nix ({ lib, ... }: { + name = "grub"; + + meta = with lib.maintainers; { + maintainers = [ rnhmjoj ]; + }; + + machine = { ... }: { + virtualisation.useBootLoader = true; + + boot.loader.timeout = null; + boot.loader.grub = { + enable = true; + users.alice.password = "supersecret"; + + # OCR is not accurate enough + extraConfig = "serial; terminal_output serial"; + }; + }; + + testScript = '' + def grub_login_as(user, password): + """ + Enters user and password to log into GRUB + """ + machine.wait_for_console_text("Enter username:") + machine.send_chars(user + "\n") + machine.wait_for_console_text("Enter password:") + machine.send_chars(password + "\n") + + + def grub_select_all_configurations(): + """ + Selects "All configurations" from the GRUB menu + to trigger a login request. + """ + machine.send_monitor_command("sendkey down") + machine.send_monitor_command("sendkey ret") + + + machine.start() + + # wait for grub screen + machine.wait_for_console_text("GNU GRUB") + + grub_select_all_configurations() + with subtest("Invalid credentials are rejected"): + grub_login_as("wronguser", "wrongsecret") + machine.wait_for_console_text("error: access denied.") + + grub_select_all_configurations() + with subtest("Valid credentials are accepted"): + grub_login_as("alice", "supersecret") + machine.send_chars("\n") # press enter to boot + machine.wait_for_console_text("Linux version") + + with subtest("Machine boots correctly"): + machine.wait_for_unit("multi-user.target") + ''; +})