Merge pull request #65231 from buckley310/grub-password

grub: add support for passwords

Changed files
+200 -3
nixos
doc
manual
lib
test-driver
modules
system
boot
tests
+12
nixos/doc/manual/development/writing-nixos-tests.xml
···
</varlistentry>
<varlistentry>
<term>
+
<methodname>wait_for_console_text</methodname>
+
</term>
+
<listitem>
+
<para>
+
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.
+
</para>
+
</listitem>
+
</varlistentry>
+
<varlistentry>
+
<term>
<methodname>wait_for_window</methodname>
</term>
<listitem>
+23
nixos/lib/test-driver/test-driver.py
···
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 argparse
import atexit
···
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))
···
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})
+62
nixos/modules/system/boot/loader/grub/grub.nix
···
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;
···
installed. Can be used instead of <literal>device</literal> to
install GRUB onto multiple devices.
'';
+
};
+
+
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 {
+42 -3
nixos/modules/system/boot/loader/grub/install-grub.pl
···
}
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;
···
}
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"));
···
$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";
···
# Add default entries.
$conf .= "$extraEntries\n" if $extraEntriesBeforeNixOS;
-
addEntry("NixOS - Default", $defaultConfig);
+
addEntry("NixOS - Default", $defaultConfig, "--unrestricted");
$conf .= "$extraEntries\n" unless $extraEntriesBeforeNixOS;
+1
nixos/tests/all-tests.nix
···
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 {};
+60
nixos/tests/grub.nix
···
+
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")
+
'';
+
})