-
-
Save cwyc/9de83566c92bdabd000256ed49312a53 to your computer and use it in GitHub Desktop.
secure boot ad-hoc module
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ config, lib, pkgs, ... }: | |
with lib; | |
let | |
cfg = config.boot.loader.systemd-boot-secure; | |
efi = config.boot.loader.efi; | |
systemdBootBuilder = pkgs.substituteAll { | |
src = ./systemd-boot-builder.py; | |
isExecutable = true; | |
inherit (pkgs.buildPackages) python3 sbsigntool; | |
binutils = pkgs.buildPackages.binutils-unwrapped; | |
systemd = config.systemd.package; | |
nix = config.nix.package.out; | |
timeout = if config.boot.loader.timeout != null then config.boot.loader.timeout else ""; | |
editor = if cfg.editor then "True" else "False"; | |
configurationLimit = if cfg.configurationLimit == null then 0 else cfg.configurationLimit; | |
inherit (cfg) consoleMode; | |
inherit (efi) efiSysMountPoint canTouchEfiVariables; | |
memtest86 = if cfg.memtest86.enable then pkgs.memtest86-efi else ""; | |
inherit (cfg) signed; | |
signingKey = if cfg.signed then cfg.signing-key else "/no-signing-key"; | |
signingCertificate = if cfg.signed then cfg.signing-certificate else "/no-signing-crt"; | |
}; | |
checkedSystemdBootBuilder = pkgs.runCommand "systemd-boot" { | |
nativeBuildInputs = [ pkgs.mypy ]; | |
} '' | |
install -m755 ${systemdBootBuilder} $out | |
mypy \ | |
--no-implicit-optional \ | |
--disallow-untyped-calls \ | |
--disallow-untyped-defs \ | |
$out | |
''; | |
in { | |
imports = | |
[ (mkRenamedOptionModule [ "boot" "loader" "gummiboot-secure" "enable" ] [ "boot" "loader" "systemd-boot-secure" "enable" ]) | |
]; | |
options.boot.loader.systemd-boot-secure = { | |
enable = mkOption { | |
default = false; | |
type = types.bool; | |
description = "Whether to enable the systemd-boot (formerly gummiboot) EFI boot manager"; | |
}; | |
signed = mkOption { | |
default = false; | |
type = types.bool; | |
description = '' | |
Whether or not the bootloader files, including systemd-boot | |
EFI programs should be signed. | |
''; | |
}; | |
signing-key = mkOption { | |
type = types.path; | |
example = "/root/secure-boot/db.key"; | |
description = '' | |
The <literal>db.key</literal> signing key, for signing EFI | |
programs. Note: Do not pass a store path. Passing the key like | |
<literal>signing-key = ./db.key;</literal> will copy the | |
private key in to the Nix store and make it world-readable. | |
Instead, pass the path as an absolute path string, like: | |
<literal>signing-key = "/root/secure-boot/db.key";</literal>. | |
''; | |
}; | |
signing-certificate = mkOption { | |
type = types.path; | |
example = "/root/secure-boot/db.crt"; | |
description = '' | |
The <literal>db.crt</literal> signing certificate, for signing | |
EFI programs. Note: certificate files are not private. | |
''; | |
}; | |
editor = mkOption { | |
default = true; | |
type = types.bool; | |
description = '' | |
Whether to allow editing the kernel command-line before | |
boot. It is recommended to set this to false, as it allows | |
gaining root access by passing init=/bin/sh as a kernel | |
parameter. However, it is enabled by default for backwards | |
compatibility. | |
''; | |
}; | |
configurationLimit = mkOption { | |
default = null; | |
example = 120; | |
type = types.nullOr types.int; | |
description = '' | |
Maximum number of latest generations in the boot menu. | |
Useful to prevent boot partition running out of disk space. | |
<literal>null</literal> means no limit i.e. all generations | |
that were not garbage collected yet. | |
''; | |
}; | |
consoleMode = mkOption { | |
default = "keep"; | |
type = types.enum [ "0" "1" "2" "auto" "max" "keep" ]; | |
description = '' | |
The resolution of the console. The following values are valid: | |
<itemizedlist> | |
<listitem><para> | |
<literal>"0"</literal>: Standard UEFI 80x25 mode | |
</para></listitem> | |
<listitem><para> | |
<literal>"1"</literal>: 80x50 mode, not supported by all devices | |
</para></listitem> | |
<listitem><para> | |
<literal>"2"</literal>: The first non-standard mode provided by the device firmware, if any | |
</para></listitem> | |
<listitem><para> | |
<literal>"auto"</literal>: Pick a suitable mode automatically using heuristics | |
</para></listitem> | |
<listitem><para> | |
<literal>"max"</literal>: Pick the highest-numbered available mode | |
</para></listitem> | |
<listitem><para> | |
<literal>"keep"</literal>: Keep the mode selected by firmware (the default) | |
</para></listitem> | |
</itemizedlist> | |
''; | |
}; | |
memtest86 = { | |
enable = mkOption { | |
default = false; | |
type = types.bool; | |
description = '' | |
Make MemTest86 available from the systemd-boot menu. MemTest86 is a | |
program for testing memory. MemTest86 is an unfree program, so | |
this requires <literal>allowUnfree</literal> to be set to | |
<literal>true</literal>. | |
''; | |
}; | |
}; | |
}; | |
config = mkIf cfg.enable { | |
assertions = [ | |
{ | |
assertion = (config.boot.kernelPackages.kernel.features or { efiBootStub = true; }) ? efiBootStub; | |
message = "This kernel does not support the EFI boot stub"; | |
} | |
]; | |
boot.loader.grub.enable = mkDefault false; | |
boot.loader.supportsInitrdSecrets = true; | |
system = { | |
build.installBootLoader = checkedSystemdBootBuilder; | |
boot.loader.id = "systemd-boot"; | |
requiredKernelConfig = with config.lib.kernelConfig; [ | |
(isYes "EFI_STUB") | |
]; | |
}; | |
}; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#! @python3@/bin/python3 -B | |
import argparse | |
import shutil | |
import os | |
import sys | |
import errno | |
import subprocess | |
import glob | |
import tempfile | |
import warnings | |
import ctypes | |
libc = ctypes.CDLL("libc.so.6") | |
import re | |
import datetime | |
import os.path | |
from typing import Tuple, List, Optional | |
def copy_if_not_exists(source: str, dest: str) -> None: | |
if not os.path.exists(dest): | |
shutil.copyfile(source, dest) | |
def system_dir(profile: Optional[str], generation: int) -> str: | |
if profile: | |
return "/nix/var/nix/profiles/system-profiles/%s-%d-link" % (profile, generation) | |
else: | |
return "/nix/var/nix/profiles/system-%d-link" % (generation) | |
SECURE_BOOT_ENTRY = """title NixOS{profile} | |
version Generation {generation} {description} (Secure Boot) | |
efi {efi} | |
""" | |
BOOT_ENTRY = """title NixOS{profile} | |
version Generation {generation} {description} | |
linux {kernel} | |
initrd {initrd} | |
options {kernel_params} | |
""" | |
# The boot loader entry for memtest86. | |
# | |
# TODO: This is hard-coded to use the 64-bit EFI app, but it could probably | |
# be updated to use the 32-bit EFI app on 32-bit systems. The 32-bit EFI | |
# app filename is BOOTIA32.efi. | |
MEMTEST_BOOT_ENTRY = """title MemTest86 | |
efi /efi/memtest86/BOOTX64.efi | |
""" | |
def write_loader_conf(profile: Optional[str], generation: int) -> None: | |
with open("@efiSysMountPoint@/loader/loader.conf.tmp", 'w') as f: | |
if "@timeout@" != "": | |
f.write("timeout @timeout@\n") | |
if profile: | |
f.write("default nixos-%s-generation-%d.conf\n" % (profile, generation)) | |
else: | |
f.write("default nixos-generation-%d.conf\n" % (generation)) | |
if not @editor@: | |
f.write("editor 0\n"); | |
f.write("console-mode @consoleMode@\n"); | |
os.rename("@efiSysMountPoint@/loader/loader.conf.tmp", "@efiSysMountPoint@/loader/loader.conf") | |
def profile_path(profile: Optional[str], generation: int, name: str) -> str: | |
return os.readlink("%s/%s" % (system_dir(profile, generation), name)) | |
def copy_from_profile(profile: Optional[str], generation: int, name: str, dry_run: bool = False) -> str: | |
store_file_path = profile_path(profile, generation, name) | |
suffix = os.path.basename(store_file_path) | |
store_dir = os.path.basename(os.path.dirname(store_file_path)) | |
efi_file_path = "/efi/nixos/%s-%s.efi" % (store_dir, suffix) | |
if not dry_run: | |
copy_if_not_exists(store_file_path, "@efiSysMountPoint@%s" % (efi_file_path)) | |
return efi_file_path | |
def describe_generation(generation_dir: str) -> str: | |
try: | |
with open("%s/nixos-version" % generation_dir) as f: | |
nixos_version = f.read() | |
except IOError: | |
nixos_version = "Unknown" | |
kernel_dir = os.path.dirname(os.path.realpath("%s/kernel" % generation_dir)) | |
module_dir = glob.glob("%s/lib/modules/*" % kernel_dir)[0] | |
kernel_version = os.path.basename(module_dir) | |
build_time = int(os.path.getctime(generation_dir)) | |
build_date = datetime.datetime.fromtimestamp(build_time).strftime('%F') | |
description = "NixOS {}, Linux Kernel {}, Built on {}".format( | |
nixos_version, kernel_version, build_date | |
) | |
return description | |
def write_entry(profile: Optional[str], generation: int, machine_id: str) -> None: | |
kernel = copy_from_profile(profile, generation, "kernel") | |
initrd = copy_from_profile(profile, generation, "initrd") | |
try: | |
append_initrd_secrets = profile_path(profile, generation, "append-initrd-secrets") | |
subprocess.check_call([append_initrd_secrets, "@efiSysMountPoint@%s" % (initrd)]) | |
except FileNotFoundError: | |
pass | |
if profile: | |
entry_file = "@efiSysMountPoint@/loader/entries/nixos-%s-generation-%d.conf" % (profile, generation) | |
else: | |
entry_file = "@efiSysMountPoint@/loader/entries/nixos-generation-%d.conf" % (generation) | |
generation_dir = os.readlink(system_dir(profile, generation)) | |
tmp_path = "%s.tmp" % (entry_file) | |
kernel_params = "init=%s/init " % generation_dir | |
with open("%s/kernel-params" % (generation_dir)) as params_file: | |
kernel_params = kernel_params + params_file.read() | |
with open(tmp_path, 'w') as f: | |
f.write(BOOT_ENTRY.format(profile=" [" + profile + "]" if profile else "", | |
generation=generation, | |
kernel=kernel, | |
initrd=initrd, | |
kernel_params=kernel_params, | |
description=describe_generation(generation_dir))) | |
if machine_id is not None: | |
f.write("machine-id %s\n" % machine_id) | |
os.rename(tmp_path, entry_file) | |
def sb_efi_file_name_relative(profile: Optional[str], generation: int) -> str: | |
if profile: | |
return "efi/nixos/nixos-%s-generation-%d.efi" % (profile, generation) | |
else: | |
return "efi/nixos/nixos-generation-%d.efi" % (generation) | |
def make_signed_efi(profile: Optional[str], generation: int, efi_file: str) -> None: | |
with tempfile.TemporaryDirectory() as tmpdir: | |
append_initrd_secrets = profile_path(profile, generation, "append-initrd-secrets") | |
if os.path.exists(append_initrd_secrets): | |
initrd = f"{tmpdir}/initrd" | |
shutil.copyfile( | |
profile_path(profile, generation, "initrd"), | |
initrd | |
) | |
subprocess.check_call([append_initrd_secrets, "@efiSysMountPoint@%s" % (initrd)]) | |
else: | |
initrd = profile_path(profile, generation, "initrd") | |
generation_dir = os.readlink(system_dir(profile, generation)) | |
tmp_path = "%s/efistub" % (tmpdir) | |
kernel_params = "systemConfig=%s init=%s/init " % (generation_dir, generation_dir) | |
with open("%s/kernel-params" % (generation_dir)) as params_file: | |
kernel_params = kernel_params + params_file.read() | |
kernel_param_file = "%s/kernel_params" % tmpdir | |
with open(kernel_param_file, 'w') as f: | |
f.write(kernel_params) | |
subprocess.check_call([ | |
"@binutils@/bin/objcopy", | |
"--add-section", ".osrel={}/etc/os-release".format(generation_dir), "--change-section-vma", ".osrel=0x20000", | |
"--add-section", ".cmdline={}".format(kernel_param_file), "--change-section-vma", ".cmdline=0x30000", | |
"--add-section", ".linux={}/kernel".format(generation_dir), "--change-section-vma", ".linux=0x40000", | |
"--add-section", ".initrd={}".format(initrd), "--change-section-vma", ".initrd=0x3000000", | |
"{}/sw/lib/systemd/boot/efi/linuxx64.efi.stub".format(generation_dir), | |
tmp_path | |
]) | |
sign_path(tmp_path, efi_file) | |
def write_secureboot_entry(profile: Optional[str], generation: int, machine_id: str) -> None: | |
if profile: | |
entry_file = "@efiSysMountPoint@/loader/entries/nixos-%s-generation-%d.conf" % (profile, generation) | |
else: | |
entry_file = "@efiSysMountPoint@/loader/entries/nixos-generation-%d.conf" % (generation) | |
efi_file_relative = sb_efi_file_name_relative(profile, generation) | |
efi_file = "@efiSysMountPoint@/%s" % (efi_file_relative) | |
try: | |
sbverify(efi_file) | |
except: | |
make_signed_efi(profile, generation, efi_file) | |
generation_dir = os.readlink(system_dir(profile, generation)) | |
entry_tmp = entry_file + ".tmp"; | |
with open(entry_tmp, 'w') as fp: | |
fp.write(SECURE_BOOT_ENTRY.format( | |
profile=" [" + profile + "]" if profile else "", | |
generation=generation, | |
efi=efi_file_relative, | |
description=describe_generation(generation_dir) | |
)) | |
if machine_id is not None: | |
fp.write("machine-id %s\n" % machine_id) | |
os.rename(entry_tmp, entry_file) | |
def sign_path(src: str, output: str) -> None: | |
with tempfile.TemporaryDirectory() as tmpdir: | |
print(f"Signing {output}") | |
subprocess.check_call([ | |
"@sbsigntool@/bin/sbsign", | |
"--key", "@signingKey@", | |
"--cert", "@signingCertificate@", | |
"--output", f"{tmpdir}/signed", | |
src | |
]) | |
# Very likely to move across filesystems, so use | |
# shutil.move over os.rename. | |
shutil.move(f"{tmpdir}/signed", f"{output}.tmp") | |
try: | |
sbverify(f"{output}.tmp") | |
os.rename(f"{output}.tmp", output) | |
except: | |
os.unlink(f"{output}.tmp") | |
raise | |
def sbverify(filename: str) -> None: | |
subprocess.check_call([ | |
"@sbsigntool@/bin/sbverify", | |
"--cert", "@signingCertificate@", | |
filename, | |
]) | |
def mkdir_p(path: str) -> None: | |
try: | |
os.makedirs(path) | |
except OSError as e: | |
if e.errno != errno.EEXIST or not os.path.isdir(path): | |
raise | |
def get_generations(profile: Optional[str] = None) -> List[Tuple[Optional[str], int]]: | |
gen_list = subprocess.check_output([ | |
"@nix@/bin/nix-env", | |
"--list-generations", | |
"-p", | |
"/nix/var/nix/profiles/%s" % ("system-profiles/" + profile if profile else "system"), | |
"--option", "build-users-group", ""], | |
universal_newlines=True) | |
gen_lines = gen_list.split('\n') | |
gen_lines.pop() | |
configurationLimit = @configurationLimit@ | |
return [ (profile, int(line.split()[0])) for line in gen_lines ][-configurationLimit:] | |
def remove_old_entries(gens: List[Tuple[Optional[str], int]]) -> None: | |
rex_profile = re.compile("^@efiSysMountPoint@/loader/entries/nixos-(.*)-generation-.*\.conf$") | |
rex_generation = re.compile("^@efiSysMountPoint@/loader/entries/nixos.*-generation-(.*)\.conf$") | |
known_paths = [] | |
for gen in gens: | |
known_paths.append(copy_from_profile(*gen, "kernel", True)) | |
known_paths.append(copy_from_profile(*gen, "initrd", True)) | |
known_paths.append("@efiSysMountPoint@/%s" % sb_efi_file_name_relative(*gen)) | |
for path in glob.iglob("@efiSysMountPoint@/loader/entries/nixos*-generation-[1-9]*.conf"): | |
try: | |
if rex_profile.match(path): | |
prof = rex_profile.sub(r"\1", path) | |
else: | |
prof = "system" | |
gen_number = int(rex_generation.sub(r"\1", path)) | |
if not (prof, gen_number) in gens: | |
os.unlink(path) | |
except ValueError: | |
pass | |
for path in glob.iglob("@efiSysMountPoint@/efi/nixos/*"): | |
if not path in known_paths and not os.path.isdir(path): | |
os.unlink(path) | |
def get_profiles() -> List[str]: | |
if os.path.isdir("/nix/var/nix/profiles/system-profiles/"): | |
return [x | |
for x in os.listdir("/nix/var/nix/profiles/system-profiles/") | |
if not x.endswith("-link")] | |
else: | |
return [] | |
def main() -> None: | |
parser = argparse.ArgumentParser(description='Update NixOS-related systemd-boot files') | |
parser.add_argument('default_config', metavar='DEFAULT-CONFIG', help='The default NixOS config to boot') | |
args = parser.parse_args() | |
try: | |
with open("/etc/machine-id") as machine_file: | |
machine_id = machine_file.readlines()[0] | |
except IOError as e: | |
if e.errno != errno.ENOENT: | |
raise | |
# Since systemd version 232 a machine ID is required and it might not | |
# be there on newly installed systems, so let's generate one so that | |
# bootctl can find it and we can also pass it to write_entry() later. | |
cmd = ["@systemd@/bin/systemd-machine-id-setup", "--print"] | |
machine_id = subprocess.run( | |
cmd, text=True, check=True, stdout=subprocess.PIPE | |
).stdout.rstrip() | |
if os.getenv("NIXOS_INSTALL_GRUB") == "1": | |
warnings.warn("NIXOS_INSTALL_GRUB env var deprecated, use NIXOS_INSTALL_BOOTLOADER", DeprecationWarning) | |
os.environ["NIXOS_INSTALL_BOOTLOADER"] = "1" | |
if os.getenv("NIXOS_INSTALL_BOOTLOADER") == "1": | |
# bootctl uses fopen() with modes "wxe" and fails if the file exists. | |
if os.path.exists("@efiSysMountPoint@/loader/loader.conf"): | |
os.unlink("@efiSysMountPoint@/loader/loader.conf") | |
if "@canTouchEfiVariables@" == "1": | |
subprocess.check_call(["@systemd@/bin/bootctl", "--path=@efiSysMountPoint@", "install"]) | |
else: | |
subprocess.check_call(["@systemd@/bin/bootctl", "--path=@efiSysMountPoint@", "--no-variables", "install"]) | |
else: | |
# Update bootloader to latest if needed | |
systemd_version = subprocess.check_output(["@systemd@/bin/bootctl", "--version"], universal_newlines=True).split()[1] | |
sdboot_status = subprocess.check_output(["@systemd@/bin/bootctl", "--path=@efiSysMountPoint@", "status"], universal_newlines=True) | |
# See status_binaries() in systemd bootctl.c for code which generates this | |
m = re.search("^\W+File:.*/EFI/(BOOT|systemd)/.*\.efi \(systemd-boot (\d+)\)$", | |
sdboot_status, re.IGNORECASE | re.MULTILINE) | |
if m is None: | |
print("could not find any previously installed systemd-boot") | |
else: | |
sdboot_version = m.group(2) | |
if systemd_version > sdboot_version: | |
print("updating systemd-boot from %s to %s" % (sdboot_version, systemd_version)) | |
subprocess.check_call(["@systemd@/bin/bootctl", "--path=@efiSysMountPoint@", "update"]) | |
mkdir_p("@efiSysMountPoint@/efi/nixos") | |
mkdir_p("@efiSysMountPoint@/loader/entries") | |
if "@signed@" == "1": | |
sign_path("@efiSysMountPoint@/EFI/BOOT/BOOTX64.EFI", "@efiSysMountPoint@/EFI/BOOT/BOOTX64.EFI") | |
sign_path("@efiSysMountPoint@/EFI/systemd/systemd-bootx64.efi", "@efiSysMountPoint@/EFI/systemd/systemd-bootx64.efi") | |
gens = get_generations() | |
for profile in get_profiles(): | |
gens += get_generations(profile) | |
remove_old_entries(gens) | |
for gen in gens: | |
try: | |
if "@signed@"== "1": | |
write_secureboot_entry(*gen, machine_id) | |
else: | |
write_entry(*gen, machine_id) | |
if os.readlink(system_dir(*gen)) == args.default_config: | |
write_loader_conf(*gen) | |
except OSError as e: | |
print("ignoring profile '{}' in the list of boot entries because of the following error:\n{}".format(profile, e), file=sys.stderr) | |
memtest_entry_file = "@efiSysMountPoint@/loader/entries/memtest86.conf" | |
if os.path.exists(memtest_entry_file): | |
os.unlink(memtest_entry_file) | |
shutil.rmtree("@efiSysMountPoint@/efi/memtest86", ignore_errors=True) | |
if "@memtest86@" != "": | |
mkdir_p("@efiSysMountPoint@/efi/memtest86") | |
for path in glob.iglob("@memtest86@/*"): | |
if os.path.isdir(path): | |
shutil.copytree(path, os.path.join("@efiSysMountPoint@/efi/memtest86", os.path.basename(path))) | |
else: | |
shutil.copy(path, "@efiSysMountPoint@/efi/memtest86/") | |
memtest_entry_file = "@efiSysMountPoint@/loader/entries/memtest86.conf" | |
memtest_entry_file_tmp_path = "%s.tmp" % memtest_entry_file | |
with open(memtest_entry_file_tmp_path, 'w') as f: | |
f.write(MEMTEST_BOOT_ENTRY) | |
os.rename(memtest_entry_file_tmp_path, memtest_entry_file) | |
# Since fat32 provides little recovery facilities after a crash, | |
# it can leave the system in an unbootable state, when a crash/outage | |
# happens shortly after an update. To decrease the likelihood of this | |
# event sync the efi filesystem after each update. | |
rc = libc.syncfs(os.open("@efiSysMountPoint@", os.O_RDONLY)) | |
if rc != 0: | |
print("could not sync @efiSysMountPoint@: {}".format(os.strerror(rc)), file=sys.stderr) | |
if __name__ == '__main__': | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment