Follow-up Comment #2, bug #61522 (group grub):

From 69db0d49b366a48167b18b37b0be88de2c299da0 Mon Sep 17 00:00:00 2001
From: Ole Kristian Aamot <[email protected]>
Date: Fri, 30 Oct 2026 02:27:35 +0100
Subject: [PATCH] efi: Add bootnext command and use it to boot Windows

Chainloading Windows Boot Manager from GRUB puts shim and GRUB into the
TPM measurements.  BitLocker's TPM protector (PCR 7, or 0/2/4/11) then
no longer matches, the volume key is not unsealed, and Windows drops to
the BitLocker recovery screen.

Add a "bootnext" command that looks up the firmware's Boot#### entry for
a given loader path, description or number, writes it to BootNext and
resets.  The firmware then boots Windows Boot Manager once, directly,
with the measurements BitLocker expects.  BootNext is consumed by the
firmware, so the regular BootOrder is left untouched.

Make 30_os-prober emit "bootnext --path" ahead of "chainloader" for
Windows EFI entries.  If no matching boot entry exists the command fails
without resetting and the entry falls through to the chainloader as
before.  GRUB_DISABLE_WINDOWS_BOOTNEXT=true restores the old behaviour.

Resolves: rhbz#2049849
Signed-off-by: Ole Kristian Aamot <[email protected]>
---
 docs/grub.texi                    |  31 +++
 grub-core/Makefile.core.def       |   6 +
 grub-core/commands/efi/bootnext.c | 314 ++++++++++++++++++++++++++++++
 util/grub-mkconfig.in             |   1 +
 util/grub.d/30_os-prober.in       |  15 ++
 5 files changed, 367 insertions(+)
 create mode 100644 grub-core/commands/efi/bootnext.c

diff --git a/docs/grub.texi b/docs/grub.texi
index d669972..28d4058 100644
--- a/docs/grub.texi
+++ b/docs/grub.texi
@@ -1564,6 +1564,14 @@ Normally, @command{grub2-mkconfig} will try to use the
external
 systems installed on the same system and generate appropriate menu entries
 for them.  Set this option to @samp{true} to disable this.
 
+@item GRUB_DISABLE_WINDOWS_BOOTNEXT
+On UEFI systems, menu entries generated for Windows first try to boot
+Windows Boot Manager directly from firmware with the @command{bootnext}
+command (@pxref{bootnext}), and only chainload it if no matching UEFI boot
+entry exists.  This keeps TPM measurements intact so that BitLocker can
+unseal its key without asking for the recovery key.  Set this option to
+@samp{true} to always chainload instead.
+
 @item GRUB_OS_PROBER_SKIP_LIST
 List of space-separated FS UUIDs of filesystems to be ignored from os-prober
 output. For efi chainloaders it's <UUID>@@<EFI FILE>
@@ -4418,6 +4426,7 @@ you forget a command, you can run the command
@command{help}
* blocklist::                   Print a block list
* blscfg::                      Load Boot Loader Specification menu entries
* boot::                        Start up your operating system
+* bootnext::                    Boot a UEFI boot entry directly from
firmware
* cat::                         Show the contents of a file
* clear::                       Clear the screen
* cmosclean::                   Clear bit in CMOS
@@ -4681,6 +4690,28 @@ a menu entry).
 @end deffn
 
 
+@node bootnext
+@subsection bootnext
+
+@deffn Command bootnext [@option{--set-only}] @option{--path} file
+@deffnx Command bootnext [@option{--set-only}] @option{--description} text
+@deffnx Command bootnext [@option{--set-only}] number
+Select one of the firmware's @samp{Boot####} entries, write its number to
+the UEFI @samp{BootNext} variable and reset the machine, so that the
+firmware boots that entry once, directly, without GRUB in the chain.  The
+entry is chosen by its loader @var{file} (case-insensitive, either
+separator), by a substring of its description, or by its hexadecimal
+@var{number}.  Only active entries listed in @samp{BootOrder} are
+considered.  With @option{--set-only}, the variable is set but the machine
+is not reset.
+
+This is the way to start loaders that seal secrets to the TPM, such as
+Windows Boot Manager with BitLocker, because chainloading them from GRUB
+changes the measured boot path.  If no entry matches, the command fails
+without resetting, so a menu entry can fall back to @command{chainloader}.
+@end deffn
+
+
 @node cat
 @subsection cat
 
diff --git a/grub-core/Makefile.core.def b/grub-core/Makefile.core.def
index a545ee4..14aba34 100644
--- a/grub-core/Makefile.core.def
+++ b/grub-core/Makefile.core.def
@@ -852,6 +852,12 @@ module = {
   enable = efi;
 };
 
+module = {
+  name = bootnext;
+  efi = commands/efi/bootnext.c;
+  enable = efi;
+};
+
 module = {
   name = efifwsetup;
   efi = commands/efi/efifwsetup.c;
diff --git a/grub-core/commands/efi/bootnext.c
b/grub-core/commands/efi/bootnext.c
new file mode 100644
index 0000000..f8b34f5
--- /dev/null
+++ b/grub-core/commands/efi/bootnext.c
@@ -0,0 +1,314 @@
+/* bootnext.c - Boot a UEFI boot entry directly via BootNext + reset.  */
+/*
+ *  GRUB  --  GRand Unified Bootloader
+ *  Copyright (C) 2026  Free Software Foundation, Inc.
+ *
+ *  GRUB is free software: you can redistribute it and/or modify
+ *  it under the terms of the GNU General Public License as published by
+ *  the Free Software Foundation, either version 3 of the License, or
+ *  (at your option) any later version.
+ *
+ *  GRUB is distributed in the hope that it will be useful,
+ *  but WITHOUT ANY WARRANTY; without even the implied warranty of
+ *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ *  GNU General Public License for more details.
+ *
+ *  You should have received a copy of the GNU General Public License
+ *  along with GRUB.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+/*
+ * Chainloading an OS loader from GRUB changes the TPM measurements
+ * (shim and GRUB end up in PCR 4/7), so loaders that seal secrets
+ * against the firmware boot path -- most notably Windows Boot Manager
+ * with BitLocker -- refuse to unseal and drop to recovery.
+ *
+ * Instead, this command finds the firmware's own Boot#### entry for the
+ * loader, writes its number to BootNext and resets.  The firmware then
+ * boots that entry once, directly, with the measurements it expects.
+ *
+ * Usage:
+ *   bootnext [--set-only] --path FILE
+ *   bootnext [--set-only] --description TEXT
+ *   bootnext [--set-only] XXXX
+ *
+ * Returns an error (and does not reset) if no matching entry exists, so
+ * a menu entry can fall back to "chainloader" afterwards.
+ */
+
+#include <grub/types.h>
+#include <grub/mm.h>
+#include <grub/misc.h>
+#include <grub/err.h>
+#include <grub/charset.h>
+#include <grub/efi/api.h>
+#include <grub/efi/efi.h>
+#include <grub/extcmd.h>
+#include <grub/i18n.h>
+
+GRUB_MOD_LICENSE ("GPLv3+");
+
+static const struct grub_arg_option options[] =
+  {
+    {"path", 'p', 0, N_("Select the entry whose loader file is FILE."),
+     N_("FILE"), ARG_TYPE_STRING},
+    {"description", 'd', 0,
+     N_("Select the entry whose description contains TEXT."),
+     N_("TEXT"), ARG_TYPE_STRING},
+    {"set-only", 's', 0, N_("Set BootNext but do not reset."), 0, 0},
+    {0, 0, 0, 0, 0, 0}
+  };
+
+enum { OPT_PATH, OPT_DESC, OPT_SET_ONLY };
+
+/* Fixed header of an EFI_LOAD_OPTION.  */
+struct load_option_hdr
+{
+  grub_uint32_t attributes;
+  grub_uint16_t file_path_list_length;
+} GRUB_PACKED;
+
+#define LOAD_OPTION_ACTIVE 0x00000001
+
+/* Lower-case, and treat '/' and '\' as the same separator.  */
+static int
+path_char (int c)
+{
+  if (c == '/')
+    return '\\';
+  return grub_tolower (c);
+}
+
+/* Compare two paths, ignoring case, separators and a leading separator.  */
+static int
+paths_equal (const char *a, const char *b)
+{
+  while (*a == '/' || *a == '\\')
+    a++;
+  while (*b == '/' || *b == '\\')
+    b++;
+  for (; *a && *b; a++, b++)
+    if (path_char (*a) != path_char (*b))
+      return 0;
+  return *a == *b;
+}
+
+static int
+contains_nocase (const char *hay, const char *needle)
+{
+  grub_size_t n = grub_strlen (needle);
+
+  for (; *hay; hay++)
+    if (grub_strncasecmp (hay, needle, n) == 0)
+      return 1;
+  return 0;
+}
+
+/*
+ * Convert a UCS-2 string of at most MAX units, stored at a possibly
+ * unaligned address, to a fresh UTF-8 string.
+ */
+static char *
+ucs2_to_utf8 (const void *src, grub_size_t max)
+{
+  grub_efi_char16_t *buf;
+  grub_size_t len;
+  char *out = NULL;
+
+  buf = grub_calloc (max + 1, sizeof (*buf));
+  if (!buf)
+    return NULL;
+  grub_memcpy (buf, src, max * sizeof (*buf));
+  for (len = 0; len < max && buf[len]; len++)
+    ;
+  out = grub_malloc (len * GRUB_MAX_UTF8_PER_UTF16 + 1);
+  if (out)
+    *grub_utf16_to_utf8 ((grub_uint8_t *) out, buf, len) = '\0';
+  grub_free (buf);
+  return out;
+}
+
+/*
+ * Concatenate all file-path media nodes of the first device path
+ * instance into one UTF-8 string.  Returns NULL if there are none.
+ */
+static char *
+file_path_of (const grub_efi_device_path_t *dp, grub_size_t dp_len)
+{
+  const char *end = (const char *) dp + dp_len;
+  char *result = NULL;
+
+  while ((const char *) dp + sizeof (*dp) <= end
+        && GRUB_EFI_DEVICE_PATH_LENGTH (dp) >= sizeof (*dp)
+        && (const char *) dp + GRUB_EFI_DEVICE_PATH_LENGTH (dp) <= end
+        && GRUB_EFI_DEVICE_PATH_TYPE (dp) != GRUB_EFI_END_DEVICE_PATH_TYPE)
+    {
+      if (GRUB_EFI_DEVICE_PATH_TYPE (dp) == GRUB_EFI_MEDIA_DEVICE_PATH_TYPE
+         && GRUB_EFI_DEVICE_PATH_SUBTYPE (dp)
+            == GRUB_EFI_FILE_PATH_DEVICE_PATH_SUBTYPE)
+       {
+         const char *fp = (const char *) dp;
+         grub_size_t units = (GRUB_EFI_DEVICE_PATH_LENGTH (dp) - sizeof (*dp))
+                             / sizeof (grub_efi_char16_t);
+         char *part = ucs2_to_utf8 (fp + sizeof (*dp), units);
+         char *joined;
+
+         if (!part)
+           break;
+         if (!result)
+           joined = part;
+         else
+           {
+             joined = grub_xasprintf ("%s\\%s", result, part);
+             grub_free (part);
+             grub_free (result);
+           }
+         result = joined;
+         if (!result)
+           break;
+       }
+      dp = GRUB_EFI_NEXT_DEVICE_PATH (dp);
+    }
+
+  return result;
+}
+
+/* Return 1 if Boot#### NUM matches the requested path or description.  */
+static int
+entry_matches (grub_uint16_t num, const char *want_path, const char
*want_desc)
+{
+  static grub_guid_t global = GRUB_EFI_GLOBAL_VARIABLE_GUID;
+  char name[sizeof ("Boot0000")];
+  grub_uint8_t *data = NULL;
+  grub_size_t size = 0, off, desc_units = 0;
+  const struct load_option_hdr *hdr;
+  const grub_efi_char16_t *desc;
+  char *str = NULL;
+  int ret = 0;
+
+  grub_snprintf (name, sizeof (name), "Boot%04X", num);
+  if (grub_efi_get_variable (name, &global, &size, (void **) &data)
+      != GRUB_EFI_SUCCESS || !data || size < sizeof (*hdr))
+    goto out;
+
+  hdr = (const void *) data;
+  if (!(hdr->attributes & LOAD_OPTION_ACTIVE))
+    goto out;
+
+  /* Description: NUL-terminated UCS-2 after the header.  */
+  off = sizeof (*hdr);
+  desc = (const void *) (data + off);
+  while (off + 2 <= size && desc[desc_units])
+    {
+      desc_units++;
+      off += 2;
+    }
+  if (off + 2 > size)
+    goto out;
+  off += 2;
+
+  if (want_desc)
+    {
+      str = ucs2_to_utf8 (desc, desc_units);
+      ret = str && contains_nocase (str, want_desc);
+    }
+  else if (want_path)
+    {
+      grub_size_t dp_len = hdr->file_path_list_length;
+
+      if (dp_len > size - off)
+       goto out;
+      str = file_path_of ((const void *) (data + off), dp_len);
+      ret = str && paths_equal (str, want_path);
+    }
+
+ out:
+  grub_free (str);
+  grub_free (data);
+  grub_errno = GRUB_ERR_NONE;
+  return ret;
+}
+
+static grub_err_t
+find_entry (const char *want_path, const char *want_desc, grub_uint16_t
*out)
+{
+  static grub_guid_t global = GRUB_EFI_GLOBAL_VARIABLE_GUID;
+  grub_uint16_t *order = NULL;
+  grub_size_t size = 0, i;
+
+  if (grub_efi_get_variable ("BootOrder", &global, &size, (void **) &order)
+      == GRUB_EFI_SUCCESS && order)
+    for (i = 0; i < size / sizeof (*order); i++)
+      if (entry_matches (order[i], want_path, want_desc))
+       {
+         *out = order[i];
+         grub_free (order);
+         return GRUB_ERR_NONE;
+       }
+  grub_free (order);
+
+  return grub_error (GRUB_ERR_FILE_NOT_FOUND,
+                    N_("no active UEFI boot entry matches `%s'"),
+                    want_path ? : want_desc);
+}
+
+static grub_err_t
+grub_cmd_bootnext (grub_extcmd_context_t ctxt, int argc, char **args)
+{
+  static grub_guid_t global = GRUB_EFI_GLOBAL_VARIABLE_GUID;
+  struct grub_arg_list *state = ctxt->state;
+  grub_uint16_t num;
+  grub_err_t err;
+
+  if (state[OPT_PATH].set + state[OPT_DESC].set + (argc > 0) != 1)
+    return grub_error (GRUB_ERR_BAD_ARGUMENT,
+                      N_("specify exactly one of --path, --description "
+                         "or a boot entry number"));
+
+  if (argc > 0)
+    {
+      const char *end;
+      unsigned long v = grub_strtoul (args[0], &end, 16);
+
+      if (grub_errno || *end || end == args[0] || v > 0xffff)
+       return grub_error (GRUB_ERR_BAD_ARGUMENT,
+                          N_("invalid boot entry number `%s'"), args[0]);
+      num = v;
+    }
+  else
+    {
+      err = find_entry (state[OPT_PATH].set ? state[OPT_PATH].arg : NULL,
+                       state[OPT_DESC].set ? state[OPT_DESC].arg : NULL,
+                       &num);
+      if (err)
+       return err;
+    }
+
+  err = grub_efi_set_variable ("BootNext", &global, &num, sizeof (num));
+  if (err)
+    return err;
+
+  if (state[OPT_SET_ONLY].set)
+    {
+      grub_printf ("BootNext=%04X\n", num);
+      return GRUB_ERR_NONE;
+    }
+
+  grub_reboot ();
+  return GRUB_ERR_BUG;
+}
+
+static grub_extcmd_t cmd;
+
+GRUB_MOD_INIT (bootnext)
+{
+  cmd = grub_register_extcmd ("bootnext", grub_cmd_bootnext, 0,
+                             N_("[--set-only] --path FILE | --description TEXT 
| XXXX"),
+                             N_("Set UEFI BootNext to a firmware boot entry 
and reset."),
+                             options);
+}
+
+GRUB_MOD_FINI (bootnext)
+{
+  grub_unregister_extcmd (cmd);
+}
diff --git a/util/grub-mkconfig.in b/util/grub-mkconfig.in
index 1cfb587..9cdc7f0 100644
--- a/util/grub-mkconfig.in
+++ b/util/grub-mkconfig.in
@@ -264,6 +264,7 @@ export GRUB_DEFAULT \
   GRUB_THEME \
   GRUB_GFXPAYLOAD_LINUX \
   GRUB_DISABLE_OS_PROBER \
+  GRUB_DISABLE_WINDOWS_BOOTNEXT \
   GRUB_INIT_TUNE \
   GRUB_SAVEDEFAULT \
   GRUB_ENABLE_CRYPTODISK \
diff --git a/util/grub.d/30_os-prober.in b/util/grub.d/30_os-prober.in
index 18fe02c..edc37e9 100644
--- a/util/grub.d/30_os-prober.in
+++ b/util/grub.d/30_os-prober.in
@@ -149,6 +149,21 @@ EOF
       save_default_entry | sed -e "s/^/\t/"
       prepare_grub_to_access_device ${DEVICE} | sed -e "s/^/\t/"
 
+      case ${LONGNAME} in
+       Windows*)
+         if [ "x${GRUB_DISABLE_WINDOWS_BOOTNEXT}" != "xtrue" ]; then
+           # Boot Windows Boot Manager straight from firmware so that
+           # TPM-sealed BitLocker keys unseal (rhbz#2049849).  Falls
+           # through to chainloader if no matching Boot#### entry exists.
+           cat <<EOF
+       if insmod bootnext; then
+               bootnext --path '${EFIPATH}'
+       fi
+EOF
+         fi
+       ;;
+      esac
+
       cat <<EOF
        chainloader ${EFIPATH}
 }
-- 
2.43.0

(file #59158)

    _______________________________________________________

Additional Item Attachment:

Name: 0001-efi-Add-bootnext-command-and-use-it-to-boot-Windows.patch Size:
14KiB

<https://file.savannah.gnu.org/file/0001-efi-Add-bootnext-command-and-use-it-to-boot-Windows.patch?file_id=59158>


    AGPL NOTICE

These attachments are served by Savane. You can download the corresponding
source code of Savane at
https://savannah.gnu.org/source/savane-8c6c341578ed1ff525dc746b94af9a11d30d7444.tar.gz


    _______________________________________________________

Reply to this item at:

  <https://savannah.gnu.org/bugs/?61522>

_______________________________________________
Message sent via Savannah
https://savannah.gnu.org/

Attachment: signature.asc
Description: PGP signature

Reply via email to