mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 02:11:53 +00:00
feat: add Secure Boot MOK key enrollment and module signing
Generate DKMS MOK key pair during OS install, sign all unsigned kernel modules, and enroll the MOK certificate using the user's master password. On reboot, MokManager prompts the user to complete enrollment. Re-enrolls on every boot if the key exists but isn't enrolled yet. Adds setup wizard dialog to inform the user about the MokManager prompt.
This commit is contained in:
@@ -101,6 +101,7 @@ pub enum ErrorKind {
|
||||
UpdateFailed = 77,
|
||||
Smtp = 78,
|
||||
SetSysInfo = 79,
|
||||
Bios = 80,
|
||||
}
|
||||
impl ErrorKind {
|
||||
pub fn as_str(&self) -> String {
|
||||
@@ -185,6 +186,7 @@ impl ErrorKind {
|
||||
UpdateFailed => t!("error.update-failed"),
|
||||
Smtp => t!("error.smtp"),
|
||||
SetSysInfo => t!("error.set-sys-info"),
|
||||
Bios => t!("error.bios"),
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
@@ -173,6 +173,11 @@ pub async fn init(
|
||||
RpcContext::init_auth_cookie().await?;
|
||||
local_auth.complete();
|
||||
|
||||
// Re-enroll MOK on every boot if Secure Boot key exists but isn't enrolled yet
|
||||
if let Err(e) = crate::util::mok::enroll_mok(std::path::Path::new(crate::util::mok::DKMS_MOK_PUB)).await {
|
||||
tracing::warn!("MOK enrollment failed: {e}");
|
||||
}
|
||||
|
||||
load_database.start();
|
||||
let db = cfg.db().await?;
|
||||
crate::version::Current::default().pre_init(&db).await?;
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::prelude::*;
|
||||
use crate::s9pk::merkle_archive::source::multi_cursor_file::MultiCursorFile;
|
||||
use crate::setup::SetupInfo;
|
||||
use crate::util::Invoke;
|
||||
use crate::util::io::{TmpDir, delete_file, open_file, write_file_atomic};
|
||||
use crate::util::io::{TmpDir, delete_dir, delete_file, open_file, write_file_atomic};
|
||||
use crate::util::serde::IoFormat;
|
||||
|
||||
mod gpt;
|
||||
@@ -30,12 +30,7 @@ mod mbr;
|
||||
/// Get the EFI BootCurrent entry number (the entry firmware used to boot).
|
||||
/// Returns None on non-EFI systems or if BootCurrent is not set.
|
||||
async fn get_efi_boot_current() -> Result<Option<String>, Error> {
|
||||
let efi_output = String::from_utf8(
|
||||
Command::new("efibootmgr")
|
||||
.invoke(ErrorKind::Grub)
|
||||
.await?,
|
||||
)
|
||||
.map_err(|e| Error::new(eyre!("efibootmgr output not valid UTF-8: {e}"), ErrorKind::Grub))?;
|
||||
let efi_output = String::from_utf8(Command::new("efibootmgr").invoke(ErrorKind::Grub).await?)?;
|
||||
|
||||
Ok(efi_output
|
||||
.lines()
|
||||
@@ -46,12 +41,7 @@ async fn get_efi_boot_current() -> Result<Option<String>, Error> {
|
||||
|
||||
/// Promote a specific boot entry to first in the EFI boot order.
|
||||
async fn promote_efi_entry(entry: &str) -> Result<(), Error> {
|
||||
let efi_output = String::from_utf8(
|
||||
Command::new("efibootmgr")
|
||||
.invoke(ErrorKind::Grub)
|
||||
.await?,
|
||||
)
|
||||
.map_err(|e| Error::new(eyre!("efibootmgr output not valid UTF-8: {e}"), ErrorKind::Grub))?;
|
||||
let efi_output = String::from_utf8(Command::new("efibootmgr").invoke(ErrorKind::Grub).await?)?;
|
||||
|
||||
let current_order = efi_output
|
||||
.lines()
|
||||
@@ -182,6 +172,7 @@ struct DataDrive {
|
||||
pub struct InstallOsResult {
|
||||
pub part_info: OsPartitionInfo,
|
||||
pub rootfs: TmpMountGuard,
|
||||
pub mok_enrolled: bool,
|
||||
}
|
||||
|
||||
pub async fn install_os_to(
|
||||
@@ -230,6 +221,7 @@ pub async fn install_os_to(
|
||||
delete_file(guard.path().join("config/upgrade")).await?;
|
||||
delete_file(guard.path().join("config/overlay/etc/hostname")).await?;
|
||||
delete_file(guard.path().join("config/disk.guid")).await?;
|
||||
delete_dir(guard.path().join("config/lib/modules")).await?;
|
||||
Command::new("cp")
|
||||
.arg("-r")
|
||||
.arg(guard.path().join("config"))
|
||||
@@ -265,9 +257,7 @@ pub async fn install_os_to(
|
||||
let config_path = rootfs.path().join("config");
|
||||
|
||||
if tokio::fs::metadata("/tmp/config.bak").await.is_ok() {
|
||||
if tokio::fs::metadata(&config_path).await.is_ok() {
|
||||
tokio::fs::remove_dir_all(&config_path).await?;
|
||||
}
|
||||
crate::util::io::delete_dir(&config_path).await?;
|
||||
Command::new("cp")
|
||||
.arg("-r")
|
||||
.arg("/tmp/config.bak")
|
||||
@@ -402,6 +392,28 @@ pub async fn install_os_to(
|
||||
.invoke(crate::ErrorKind::OpenSsh)
|
||||
.await?;
|
||||
|
||||
// Secure Boot: generate MOK key, sign unsigned modules, enroll MOK
|
||||
let mut mok_enrolled = false;
|
||||
if use_efi && crate::util::mok::is_secure_boot_enabled().await {
|
||||
let new_key = crate::util::mok::ensure_dkms_key(overlay.path()).await?;
|
||||
tracing::info!(
|
||||
"DKMS MOK key: {}",
|
||||
if new_key {
|
||||
"generated"
|
||||
} else {
|
||||
"already exists"
|
||||
}
|
||||
);
|
||||
|
||||
crate::util::mok::sign_unsigned_modules(overlay.path()).await?;
|
||||
|
||||
let mok_pub = overlay.path().join(crate::util::mok::DKMS_MOK_PUB.trim_start_matches('/'));
|
||||
match crate::util::mok::enroll_mok(&mok_pub).await {
|
||||
Ok(enrolled) => mok_enrolled = enrolled,
|
||||
Err(e) => tracing::warn!("MOK enrollment failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
let mut install = Command::new("chroot");
|
||||
install.arg(overlay.path()).arg("grub-install");
|
||||
if !use_efi {
|
||||
@@ -443,7 +455,11 @@ pub async fn install_os_to(
|
||||
tokio::fs::remove_dir_all(&work).await?;
|
||||
lower.unmount().await?;
|
||||
|
||||
Ok(InstallOsResult { part_info, rootfs })
|
||||
Ok(InstallOsResult {
|
||||
part_info,
|
||||
rootfs,
|
||||
mok_enrolled,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn install_os(
|
||||
@@ -500,7 +516,11 @@ pub async fn install_os(
|
||||
None
|
||||
};
|
||||
|
||||
let InstallOsResult { part_info, rootfs } = install_os_to(
|
||||
let InstallOsResult {
|
||||
part_info,
|
||||
rootfs,
|
||||
mok_enrolled,
|
||||
} = install_os_to(
|
||||
"/run/live/medium/live/filesystem.squashfs",
|
||||
&disk.logicalname,
|
||||
disk.capacity,
|
||||
@@ -529,6 +549,7 @@ pub async fn install_os(
|
||||
.mutate(|c| c.os_partitions = Some(part_info.clone()));
|
||||
|
||||
let mut setup_info = SetupInfo::default();
|
||||
setup_info.mok_enrolled = mok_enrolled;
|
||||
|
||||
if let Some(data_drive) = data_drive {
|
||||
let mut logicalname = &*data_drive.logicalname;
|
||||
@@ -612,7 +633,11 @@ pub async fn cli_install_os(
|
||||
|
||||
let use_efi = efi.unwrap_or_else(|| !matches!(partition_table, Some(PartitionTable::Mbr)));
|
||||
|
||||
let InstallOsResult { part_info, rootfs } = install_os_to(
|
||||
let InstallOsResult {
|
||||
part_info,
|
||||
rootfs,
|
||||
mok_enrolled: _,
|
||||
} = install_os_to(
|
||||
&squashfs,
|
||||
&disk,
|
||||
capacity,
|
||||
|
||||
@@ -279,6 +279,7 @@ pub enum SetupStatusRes {
|
||||
pub struct SetupInfo {
|
||||
pub guid: Option<InternedString>,
|
||||
pub attach: bool,
|
||||
pub mok_enrolled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, TS)]
|
||||
@@ -630,6 +631,7 @@ async fn fresh_setup(
|
||||
}: SetupExecuteProgress,
|
||||
) -> Result<(SetupResult, RpcContext), Error> {
|
||||
let account = AccountInfo::new(password, root_ca_start_time().await, hostname)?;
|
||||
|
||||
let db = ctx.db().await?;
|
||||
let kiosk = Some(kiosk.unwrap_or(true)).filter(|_| &*PLATFORM != "raspberrypi");
|
||||
sync_kiosk(kiosk).await?;
|
||||
|
||||
@@ -45,6 +45,7 @@ pub mod iter;
|
||||
pub mod logger;
|
||||
pub mod lshw;
|
||||
pub mod mime;
|
||||
pub mod mok;
|
||||
pub mod net;
|
||||
pub mod rpc;
|
||||
pub mod rpc_client;
|
||||
|
||||
125
core/src/util/mok.rs
Normal file
125
core/src/util/mok.rs
Normal file
@@ -0,0 +1,125 @@
|
||||
use std::path::Path;
|
||||
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::prelude::*;
|
||||
use crate::util::Invoke;
|
||||
use crate::util::io::{delete_file, maybe_open_file, write_file_atomic};
|
||||
|
||||
pub const DKMS_MOK_KEY: &str = "/var/lib/dkms/mok.key";
|
||||
pub const DKMS_MOK_PUB: &str = "/var/lib/dkms/mok.pub";
|
||||
|
||||
pub async fn is_secure_boot_enabled() -> bool {
|
||||
String::from_utf8_lossy(
|
||||
&Command::new("mokutil")
|
||||
.arg("--sb-state")
|
||||
.env("LANG", "C.UTF-8")
|
||||
.invoke(ErrorKind::Bios)
|
||||
.await
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.contains("SecureBoot enabled")
|
||||
}
|
||||
|
||||
/// Generate a DKMS MOK key pair if one doesn't exist.
|
||||
pub async fn ensure_dkms_key(root: &Path) -> Result<bool, Error> {
|
||||
let key_path = root.join(DKMS_MOK_KEY.trim_start_matches('/'));
|
||||
if maybe_open_file(&key_path).await?.is_some() {
|
||||
return Ok(false); // Already exists
|
||||
}
|
||||
Command::new("chroot")
|
||||
.arg(root)
|
||||
.arg("dkms")
|
||||
.arg("generate_mok")
|
||||
.invoke(ErrorKind::Bios)
|
||||
.await?;
|
||||
Ok(true) // Newly generated
|
||||
}
|
||||
|
||||
/// Sign all unsigned kernel modules in the given root using the DKMS MOK key.
|
||||
/// Calls the sign-unsigned-modules script inside the chroot.
|
||||
pub async fn sign_unsigned_modules(root: &Path) -> Result<(), Error> {
|
||||
Command::new("chroot")
|
||||
.arg(root)
|
||||
.arg("/usr/lib/startos/scripts/sign-unsigned-modules")
|
||||
.invoke(ErrorKind::OpenSsl)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the start9 user's password hash from /etc/shadow.
|
||||
/// Returns None if the user doesn't exist or the password is locked.
|
||||
async fn start9_shadow_hash() -> Result<Option<String>, Error> {
|
||||
let shadow = tokio::fs::read_to_string("/etc/shadow").await?;
|
||||
for line in shadow.lines() {
|
||||
if let Some(("start9", rest)) = line.split_once(':') {
|
||||
if let Some((hash, _)) = rest.split_once(':') {
|
||||
let hash = hash.trim_start_matches("!");
|
||||
if hash.starts_with('$') {
|
||||
return Ok(Some(hash.to_owned()));
|
||||
}
|
||||
// Locked or invalid password
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Enroll the DKMS MOK certificate using the start9 user's password from /etc/shadow.
|
||||
/// Idempotent: skips if already enrolled, or if the user's password is not yet set.
|
||||
/// `mok_pub` is the path to the MOK public certificate (may be inside a chroot overlay during install).
|
||||
/// Returns true if a new enrollment was staged.
|
||||
pub async fn enroll_mok(mok_pub: &Path) -> Result<bool, Error> {
|
||||
tracing::info!("enroll_mok: checking EFI and mok_pub={}", mok_pub.display());
|
||||
if tokio::fs::metadata("/sys/firmware/efi").await.is_err() {
|
||||
tracing::info!("enroll_mok: no EFI, skipping");
|
||||
return Ok(false);
|
||||
}
|
||||
if maybe_open_file(mok_pub).await?.is_none() {
|
||||
tracing::info!("enroll_mok: mok_pub not found, skipping");
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Check if already enrolled in firmware
|
||||
let test_output = Command::new("mokutil")
|
||||
.arg("--test-key")
|
||||
.arg(mok_pub)
|
||||
.env("LANG", "C.UTF-8")
|
||||
.invoke(ErrorKind::Bios)
|
||||
.await?;
|
||||
let test_str = String::from_utf8(test_output)?;
|
||||
tracing::info!("enroll_mok: mokutil --test-key output: {test_str:?}");
|
||||
if test_str.contains("is enrolled") {
|
||||
tracing::info!("enroll_mok: already enrolled, skipping");
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let Some(hash) = start9_shadow_hash().await? else {
|
||||
tracing::info!("enroll_mok: start9 user password not set, skipping");
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
// Revoke any pending enrollment (so we can re-import with current password)
|
||||
let _ = Command::new("mokutil")
|
||||
.arg("--revoke-import")
|
||||
.arg(mok_pub)
|
||||
.invoke(ErrorKind::Bios)
|
||||
.await;
|
||||
|
||||
let hash_file = Path::new("/tmp/mok-password-hash");
|
||||
write_file_atomic(hash_file, &hash).await?;
|
||||
|
||||
tracing::info!("Enrolling DKMS MOK certificate");
|
||||
let result = Command::new("mokutil")
|
||||
.arg("--import")
|
||||
.arg(mok_pub)
|
||||
.arg("--hash-file")
|
||||
.arg(hash_file)
|
||||
.invoke(ErrorKind::Bios)
|
||||
.await;
|
||||
|
||||
delete_file(hash_file).await.log_err();
|
||||
result?;
|
||||
Ok(true)
|
||||
}
|
||||
Reference in New Issue
Block a user