]> git.ipfire.org Git - thirdparty/systemd.git/commitdiff
cryptenroll: add interactive --firstboot enrollment wizard
authorLennart Poettering <lennart@amutable.com>
Thu, 28 May 2026 12:15:56 +0000 (14:15 +0200)
committerLennart Poettering <lennart@amutable.com>
Sat, 27 Jun 2026 15:28:39 +0000 (17:28 +0200)
Add a --firstboot mode that interactively walks the user through enrolling a
passphrase, a recovery key, or a FIDO2 token, with one menu entry per suitable
token currently plugged in (driven by a new fido2_enumerate_devices() helper).
Pressing enter at the top-level menu leaves the volume unchanged; for each
already-enrolled credential type the wizard offers to wipe it as part of the
operation. It populates the same EnrollContext the command line and Varlink
paths use, so the actual enrollment goes through the shared enroll_now() path.

A companion --prompt-suppress= option takes a list of slot types: if a slot of
any listed type already exists, the wizard does nothing and exits successfully.
This lets it be hooked into the boot process while staying quiet once the
system has been set up.

The accompanying systemd-cryptenroll-firstboot.service runs this from the
initrd, after systemd-repart has created the encrypted volume but before we
transition to the host, suppressing itself once a password, recovery key or
FIDO2 token is enrolled. To make that work, determine_default_node() now looks
below /sysroot/ when running in the initrd, since the host file systems aren't
at their final location yet.

While the wizard is active it draws the same installer-style chrome (blue bars
at the top and bottom of the terminal) as systemd-sysinstall, using the shared
prompt_loop_yes_no() helper for its wipe confirmations.

Honours the systemd.firstboot= kernel command line option.

Fixes: #36298
man/systemd-cryptenroll.xml
src/cryptenroll/cryptenroll-interactive.c [new file with mode: 0644]
src/cryptenroll/cryptenroll-interactive.h [new file with mode: 0644]
src/cryptenroll/cryptenroll.c
src/cryptenroll/meson.build
src/shared/libfido2-util.c
src/shared/libfido2-util.h
units/meson.build
units/systemd-cryptenroll-firstboot.service [new file with mode: 0644]

index c88d38fa435e1e23a64aaf58a914ff5b71f36e03..9f090ec94b4828f93881a268fc4fd8741d24b713 100644 (file)
     </variablelist>
   </refsect1>
 
+  <refsect1>
+    <title>Interactive Enrollment</title>
+
+    <para>The following options are understood that drive an interactive enrollment wizard, intended to be
+    run on first boot (similar in spirit to
+    <citerefentry><refentrytitle>systemd-firstboot</refentrytitle><manvolnum>1</manvolnum></citerefentry>):</para>
+
+    <variablelist>
+      <varlistentry>
+        <term><option>--firstboot</option></term>
+
+        <listitem><para>Run an interactive wizard that lets the user enroll a passphrase, a recovery key, or a
+        FIDO2 security token (one menu entry per suitable token currently plugged in). If credentials of any
+        of these types are already enrolled, the wizard additionally offers to wipe them. Pressing enter at
+        the top-level menu leaves the volume unchanged. This option may not be combined with an explicit
+        enrollment type or with <option>--wipe-slot=</option>. As with the other commands, the volume to
+        operate on is the one backing <filename>/var/</filename> if none is specified. Honours the
+        <varname>systemd.firstboot=</varname> kernel command line option.</para>
+
+        <xi:include href="version-info.xml" xpointer="v262"/></listitem>
+      </varlistentry>
+
+      <varlistentry>
+        <term><option>--prompt-suppress=<replaceable>TYPE</replaceable>…</option></term>
+
+        <listitem><para>Takes a comma-separated list of slot types, out of <literal>password</literal>,
+        <literal>recovery</literal>, <literal>pkcs11</literal>, <literal>fido2</literal> and
+        <literal>tpm2</literal>. Only useful together with <option>--firstboot</option>: if a slot of any of
+        the listed types is already enrolled on the volume, the wizard does nothing and exits successfully.
+        This is useful to hook the wizard into the boot process while ensuring it stays quiet once suitable
+        credentials have been set up.</para>
+
+        <xi:include href="version-info.xml" xpointer="v262"/></listitem>
+      </varlistentry>
+
+      <varlistentry>
+        <term><option>--chrome=<replaceable>BOOL</replaceable></option></term>
+
+        <listitem><para>Takes a boolean argument, defaults to on. Only useful together with
+        <option>--firstboot</option>: if enabled the interactive wizard draws a coloured bar across the top and
+        bottom of the terminal to visually frame the prompt. Pass <option>--chrome=no</option> to disable this
+        decoration, for example when running on a terminal that does not render it well.</para>
+
+        <xi:include href="version-info.xml" xpointer="v262"/></listitem>
+      </varlistentry>
+
+      <varlistentry>
+        <term><option>--mute-console=<replaceable>BOOL</replaceable></option></term>
+
+        <listitem><para>Takes a boolean argument, defaults to off. Only useful together with
+        <option>--firstboot</option>: if enabled the kernel and PID 1 are asked to refrain from writing log
+        messages to the console while the interactive wizard is shown, so that such output does not interfere
+        with the prompt. The console is unmuted again once the wizard exits.</para>
+
+        <xi:include href="version-info.xml" xpointer="v262"/></listitem>
+      </varlistentry>
+    </variablelist>
+  </refsect1>
+
   <refsect1>
     <title>PKCS#11 Enrollment</title>
 
diff --git a/src/cryptenroll/cryptenroll-interactive.c b/src/cryptenroll/cryptenroll-interactive.c
new file mode 100644 (file)
index 0000000..6dc584b
--- /dev/null
@@ -0,0 +1,243 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+
+#include <unistd.h>
+
+#include "alloc-util.h"
+#include "ansi-color.h"
+#include "cryptenroll.h"
+#include "cryptenroll-interactive.h"
+#include "cryptenroll-list.h"
+#include "cryptsetup-util.h"
+#include "glyph-util.h"
+#include "libfido2-util.h"
+#include "log.h"
+#include "proc-cmdline.h"
+#include "prompt-util.h"
+#include "string-util.h"
+#include "strv.h"
+#include "terminal-util.h"
+
+static int collect_existing_types(struct crypt_device *cd, unsigned *ret_mask) {
+        _cleanup_free_ EnrolledSlot *slots = NULL;
+        size_t n_slots;
+        unsigned mask = 0;
+        int r;
+
+        assert(cd);
+        assert(ret_mask);
+
+        r = collect_enrolled_slots(cd, &slots, &n_slots);
+        if (r < 0)
+                return r;
+
+        FOREACH_ARRAY(s, slots, n_slots)
+                if (!s->conflict && s->type >= 0)
+                        mask |= 1U << s->type;
+
+        *ret_mask = mask;
+        return 0;
+}
+
+static int choose_mechanism(EnrollContext *c) {
+        int r;
+
+        assert(c);
+
+        /* Top-level menu. Each iteration re-enumerates the FIDO2 tokens, so a token plugged in after the
+         * menu was first shown appears once the user picks "Rescan". An empty answer leaves the volume
+         * untouched. */
+
+        for (;;) {
+                Fido2DeviceInfo *devices = NULL;
+                size_t n_devices = 0;
+                CLEANUP_ARRAY(devices, n_devices, fido2_device_info_free_many);
+
+                /* Best effort: if FIDO2 isn't available we simply offer no token rows. */
+                (void) fido2_enumerate_devices(&devices, &n_devices);
+
+                _cleanup_strv_free_ char **menu = strv_new("Enroll a recovery key", "Enroll a passphrase");
+                if (!menu)
+                        return log_oom();
+
+                FOREACH_ARRAY(d, devices, n_devices) {
+                        _cleanup_free_ char *label = NULL;
+
+                        label = strjoin("Enroll FIDO2 security token: ",
+                                        d->manufacturer ?: "Security Token",
+                                        d->product ? " " : "", strempty(d->product),
+                                        " (", d->path, ")");
+                        if (!label)
+                                return log_oom();
+
+                        if (strv_consume(&menu, TAKE_PTR(label)) < 0)
+                                return log_oom();
+                }
+
+                if (strv_extend(&menu, "Rescan for FIDO2 security tokens") < 0)
+                        return log_oom();
+
+                _cleanup_free_ char *choice = NULL;
+                r = prompt_loop(
+                                "Select enrollment option",
+                                GLYPH_LOCK_AND_KEY,
+                                /* prefill= */ NULL,
+                                menu,
+                                /* accepted= */ menu, /* only accept exact menu entries (or their numbers) */
+                                /* ellipsize_percentage= */ 60,
+                                /* n_columns= */ 1,
+                                /* column_width= */ SIZE_MAX, /* auto-size to the widest entry */
+                                /* is_valid= */ NULL,
+                                /* refresh= */ NULL,
+                                /* userdata= */ NULL,
+                                PROMPT_MAY_SKIP|PROMPT_SHOW_MENU|PROMPT_SHOW_MENU_NOW|PROMPT_HIDE_MENU_HINT,
+                                &choice);
+                if (r < 0)
+                        return r;
+                if (!choice) {
+                        /* Empty answer: do nothing. */
+                        log_info("No selection made, leaving volume unchanged.");
+                        return 0;
+                }
+
+                /* Map the chosen label back to its index. */
+                size_t idx = SIZE_MAX;
+                STRV_FOREACH(m, menu)
+                        if (streq(*m, choice)) {
+                                idx = m - menu;
+                                break;
+                        }
+
+                assert(idx != SIZE_MAX);
+
+                if (idx == 0)
+                        c->enroll_type = ENROLL_RECOVERY;
+                else if (idx == 1)
+                        c->enroll_type = ENROLL_PASSWORD;
+                else if (idx + 1 == strv_length(menu)) /* "Rescan" */
+                        continue;
+                else {
+                        c->enroll_type = ENROLL_FIDO2;
+                        assert(idx > 1);
+                        assert(idx + 1 < strv_length(menu));
+
+                        if (strdup_to(&c->fido2_device, devices[idx - 2].path) < 0)
+                                return log_oom();
+                }
+
+                return 1;
+        }
+}
+
+static int ask_wipe(EnrollContext *c, unsigned existing_mask) {
+        static const EnrollType candidates[] = {
+                ENROLL_PASSWORD,
+                ENROLL_RECOVERY,
+                ENROLL_FIDO2,
+        };
+        int r;
+
+        assert(c);
+
+        /* For each already-enrolled type the wizard understands, offer to wipe it alongside the new
+         * enrollment. */
+
+        FOREACH_ELEMENT(t, candidates) {
+                if (!FLAGS_SET(existing_mask, 1U << *t))
+                        continue;
+
+                _cleanup_free_ char *question = NULL;
+                question = strjoin("A ", enroll_type_to_string(*t), " slot is already enrolled. Wipe it as part of this enrollment?");
+                if (!question)
+                        return log_oom();
+
+                bool wipe;
+                r = prompt_loop_yes_no(question, /* def= */ false, &wipe);
+                if (r < 0)
+                        return r;
+
+                if (wipe)
+                        c->wipe_slots_mask |= 1U << *t;
+        }
+
+        return 0;
+}
+
+int cryptenroll_run_interactive(
+                EnrollContext *c,
+                unsigned prompt_suppress_mask,
+                bool chrome,
+                sd_varlink **mute_console_link) {
+
+        _cleanup_(crypt_freep) struct crypt_device *cd = NULL;
+        unsigned existing_mask = 0;
+        int r;
+
+        assert(c);
+        assert(c->node);
+
+        /* Honour the systemd.firstboot= kernel command line option, just like systemd-firstboot. */
+        bool enabled;
+        r = proc_cmdline_get_bool("systemd.firstboot", PROC_CMDLINE_TRUE_WHEN_MISSING, &enabled);
+        if (r < 0)
+                log_warning_errno(r, "Failed to parse systemd.firstboot= kernel command line option, ignoring: %m");
+        else if (!enabled) {
+                log_debug("systemd.firstboot=no set, skipping interactive enrollment.");
+                return 0;
+        }
+
+        /* Open the volume just to inspect its header (no unlocking needed yet). */
+        r = prepare_luks(c, &cd, /* ret_volume_key= */ NULL);
+        if (r < 0)
+                return r;
+
+        r = collect_existing_types(cd, &existing_mask);
+        if (r < 0)
+                return r;
+
+        /* If a credential of a suppressed type is already enrolled, do nothing. This lets the wizard be
+         * wired into first boot but stay quiet once the system has been set up. */
+        if ((existing_mask & prompt_suppress_mask) != 0) {
+                log_debug("A credential of a suppressed type is already enrolled, skipping interactive setup.");
+                return 0;
+        }
+
+        if (existing_mask == 0) {
+                log_debug("No recognized LUKS slots, we're unlikely able to unlock, skipping interactive setup.");
+                return 0;
+        }
+
+        (void) mute_console(mute_console_link);
+
+        /* Draw the installer-style chrome (blue bars at the top and bottom) around the wizard, matching
+         * systemd-sysinstall. The caller hides it again via a deferred chrome_hide() once enrollment is
+         * complete. */
+        (void) terminal_reset_defensive_locked(STDOUT_FILENO, /* flags= */ 0);
+
+        if (chrome)
+                (void) chrome_show("Additional Disk Encryption Key Enrollment", /* bottom= */ NULL);
+
+        printf("%s%s%sLet's enroll additional disk encryption mechanisms for recovering access to the system.%s\n\n",
+               emoji_enabled() ? glyph(GLYPH_COMPUTER_DISK) : "", emoji_enabled() ? " " : "",
+               ansi_highlight(), ansi_normal());
+
+        _cleanup_free_ char *s = NULL;
+        for (EnrollType t = 0; t < _ENROLL_TYPE_MAX; t++) {
+                if (!FLAGS_SET(existing_mask, 1 << t))
+                        continue;
+
+                if (!strextend_with_separator(&s, ", ", enroll_type_to_string(t)))
+                        return log_oom();
+        }
+
+        printf("Currently enrolled mechanisms: %s\n\n", s);
+
+        r = choose_mechanism(c);
+        if (r <= 0)
+                return r;
+
+        r = ask_wipe(c, existing_mask);
+        if (r < 0)
+                return r;
+
+        return 1;
+}
diff --git a/src/cryptenroll/cryptenroll-interactive.h b/src/cryptenroll/cryptenroll-interactive.h
new file mode 100644 (file)
index 0000000..413e0d8
--- /dev/null
@@ -0,0 +1,12 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+#pragma once
+
+#include "cryptenroll.h"
+
+/* Runs the interactive first-boot enrollment wizard, populating *c (c->enroll_type, c->fido2_device,
+ * c->wipe_slots_mask). On return c->enroll_type is set to the chosen mechanism, or left
+ * _ENROLL_TYPE_INVALID if the user skipped or the wizard was suppressed.
+ *
+ * prompt_suppress_mask is a bitmask of (1U << EnrollType): if a slot of any such type already exists on the
+ * volume, the wizard does nothing (so it can be hooked into first boot but stay quiet on later boots). */
+int cryptenroll_run_interactive(EnrollContext *c, unsigned prompt_suppress_mask, bool chrome, sd_varlink **mute_console_link);
index 8c488e554f1c5039243a1c68384a6e15a4d8685d..67386f31d8b4dbcffa665b1ad164edc3090a951c 100644 (file)
@@ -9,8 +9,10 @@
 #include "blockdev-list.h"
 #include "blockdev-util.h"
 #include "build.h"
+#include "cleanup-util.h"
 #include "cryptenroll.h"
 #include "cryptenroll-fido2.h"
+#include "cryptenroll-interactive.h"
 #include "cryptenroll-list.h"
 #include "cryptenroll-password.h"
 #include "cryptenroll-pkcs11.h"
@@ -21,6 +23,8 @@
 #include "cryptsetup-util.h"
 #include "extract-word.h"
 #include "format-table.h"
+#include "help-util.h"
+#include "initrd-util.h"
 #include "libfido2-util.h"
 #include "log.h"
 #include "main-func.h"
 #include "parse-argument.h"
 #include "parse-util.h"
 #include "pkcs11-util.h"
-#include "pretty-print.h"
 #include "process-util.h"
+#include "prompt-util.h"
 #include "string-table.h"
 #include "string-util.h"
+#include "terminal-util.h"
 #include "tpm2-pcr.h"
 #include "tpm2-util.h"
 
@@ -63,6 +68,10 @@ static int *arg_wipe_slots = NULL;
 static size_t arg_n_wipe_slots = 0;
 static WipeScope arg_wipe_slots_scope = WIPE_EXPLICIT;
 static unsigned arg_wipe_slots_mask = 0; /* Bitmask of (1U << EnrollType), for wiping all slots of specific types */
+static bool arg_firstboot = false;
+static bool arg_chrome = true;
+static bool arg_mute_console = false;
+static unsigned arg_prompt_suppress_mask = 0; /* Bitmask of (1U << EnrollType): if any such slot exists, --firstboot does nothing */
 static Fido2EnrollFlags arg_fido2_lock_with = FIDO2ENROLL_PIN | FIDO2ENROLL_UP;
 #if HAVE_LIBFIDO2
 static int arg_fido2_cred_alg = COSE_ES256;
@@ -113,6 +122,20 @@ static const char *const luks2_token_type_table[_ENROLL_TYPE_MAX] = {
 
 DEFINE_STRING_TABLE_LOOKUP(luks2_token_type, EnrollType);
 
+static int enroll_type_mask_from_string(const char *name) {
+        assert(name);
+
+        /* Maps an enroll type name (command line spelling) to its (1U << EnrollType) bitmask, or returns a
+         * negative errno if the name is not a known type. Callers merge the returned mask into their own
+         * accumulator. Shared by the various places that parse type lists into a bitmask. */
+
+        EnrollType t = enroll_type_from_string(name);
+        if (t < 0)
+                return -EINVAL;
+
+        return 1 << t;
+}
+
 void enroll_context_done(EnrollContext *c) {
         if (!c)
                 return;
@@ -137,64 +160,94 @@ void enroll_context_done(EnrollContext *c) {
         c->link = sd_varlink_unref(c->link);
 }
 
-static int determine_default_node(void) {
+static int resolve_default_node(const char *path, char **ret) {
         int r;
 
-        /* If no device is specified we'll default to the backing device of /var/.
-         *
-         * Why /var/ and not just / you ask?
-         *
-         * On most systems /var/ is going to be on the root fs, hence the outcome is usually the same.
-         *
-         * However, on systems where / and /var/ are separate it makes more sense to default to /var/ because
-         * that's where the persistent and variable data is placed (i.e. where LUKS should be used) while /
-         * doesn't really have to be variable and could as well be immutable or ephemeral. Hence /var/ should
-         * be a better default.
-         *
-         * Or to say this differently: it makes sense to support well systems with /var/ being on /. It also
-         * makes sense to support well systems with them being separate, and /var/ being variable and
-         * persistent. But any other kind of system appears much less interesting to support, and in that
-         * case people should just specify the device name explicitly. */
+        /* Resolves the path of the underlying LUKS2 block device of the file system at the given mount
+         * point, i.e. the raw partition behind the dm-crypt mapping that file system sits on. Returns
+         * -ENXIO if the file system is not backed by a (single) LUKS2 device. Logs only at debug level, so
+         * the caller can try the next candidate path. */
+
+        assert(path);
+        assert(ret);
 
         dev_t devno;
-        r = get_block_device("/var", &devno);
+        r = get_block_device(path, &devno);
         if (r < 0)
-                return log_error_errno(r, "Failed to determine block device backing /var/: %m");
+                return log_debug_errno(r, "Failed to determine block device backing %s: %m", path);
         if (r == 0)
-                return log_error_errno(SYNTHETIC_ERRNO(ENXIO),
-                                       "File system /var/ is on not backed by a (single) whole block device.");
+                return log_debug_errno(SYNTHETIC_ERRNO(ENXIO),
+                                       "File system %s is not backed by a (single) whole block device.", path);
 
         _cleanup_(sd_device_unrefp) sd_device *dev = NULL;
         r = sd_device_new_from_devnum(&dev, 'b', devno);
         if (r < 0)
-                return log_error_errno(r, "Unable to access backing block device for /var/: %m");
+                return log_debug_errno(r, "Unable to access backing block device for %s: %m", path);
 
         const char *dm_uuid;
         r = sd_device_get_property_value(dev, "DM_UUID", &dm_uuid);
         if (r == -ENOENT)
-                return log_error_errno(r, "Backing block device of /var/ is not a DM device: %m");
+                return log_debug_errno(SYNTHETIC_ERRNO(ENXIO), "Backing block device of %s is not a DM device.", path);
         if (r < 0)
-                return log_error_errno(r, "Unable to query DM_UUID udev property of backing block device for /var/: %m");
+                return log_debug_errno(r, "Unable to query DM_UUID udev property of backing block device for %s: %m", path);
 
         if (!startswith(dm_uuid, "CRYPT-LUKS2-"))
-                return log_error_errno(SYNTHETIC_ERRNO(ENXIO), "Block device backing /var/ is not a LUKS2 device.");
+                return log_debug_errno(SYNTHETIC_ERRNO(ENXIO), "Block device backing %s is not a LUKS2 device.", path);
 
         _cleanup_(sd_device_unrefp) sd_device *origin = NULL;
         r = block_device_get_originating(dev, &origin, /* recursive= */ false);
         if (r < 0)
-                return log_error_errno(r, "Failed to get originating device of LUKS2 device backing /var/: %m");
+                return log_debug_errno(r, "Failed to get originating device of LUKS2 device backing %s: %m", path);
 
         const char *dp;
         r = sd_device_get_devname(origin, &dp);
         if (r < 0)
-                return log_error_errno(r, "Failed to get device path for LUKS2 device backing /var/: %m");
+                return log_debug_errno(r, "Failed to get device path for LUKS2 device backing %s: %m", path);
 
-        r = free_and_strdup_warn(&arg_node, dp);
-        if (r < 0)
-                return r;
+        return strdup_to(ret, dp);
+}
 
-        log_info("No device specified, defaulting to '%s'.", arg_node);
-        return 0;
+static int determine_default_node(void) {
+        int r;
+
+        /* If no device is specified we'll default to the backing device of /var/.
+         *
+         * Why /var/ and not just / you ask?
+         *
+         * On most systems /var/ is going to be on the root fs, hence the outcome is usually the same.
+         *
+         * However, on systems where / and /var/ are separate it makes more sense to default to /var/ because
+         * that's where the persistent and variable data is placed (i.e. where LUKS should be used) while /
+         * doesn't really have to be variable and could as well be immutable or ephemeral. Hence /var/ should
+         * be a better default.
+         *
+         * Or to say this differently: it makes sense to support well systems with /var/ being on /. It also
+         * makes sense to support well systems with them being separate, and /var/ being variable and
+         * persistent. But any other kind of system appears much less interesting to support, and in that
+         * case people should just specify the device name explicitly.
+         *
+         * When invoked from the initrd the host's file systems are not mounted at their final location yet,
+         * but below /sysroot/, hence look there instead. */
+
+        const char *candidates[2];
+        size_t n_candidates = 0;
+
+        if (in_initrd()) {
+                candidates[n_candidates++] = "/sysroot/var";
+                candidates[n_candidates++] = "/sysroot";
+        } else
+                candidates[n_candidates++] = "/var";
+
+        FOREACH_ARRAY(path, candidates, n_candidates) {
+                r = resolve_default_node(*path, &arg_node);
+                if (r >= 0) {
+                        log_info("No device specified, defaulting to '%s' (backing %s).", arg_node, *path);
+                        return 0;
+                }
+        }
+
+        return log_error_errno(SYNTHETIC_ERRNO(ENXIO),
+                               "Failed to automatically determine a LUKS2 block device to operate on, please specify one explicitly.");
 }
 
 static int parse_wipe_slot(const char *arg) {
@@ -217,21 +270,15 @@ static int parse_wipe_slot(const char *arg) {
                 if (r < 0)
                         return log_error_errno(r, "Failed to parse slot list: %s", arg);
 
+                int mask;
+
                 if (streq(slot, "all"))
                         arg_wipe_slots_scope = WIPE_ALL;
                 else if (streq(slot, "empty")) {
                         if (arg_wipe_slots_scope != WIPE_ALL) /* if "all" was specified before, that wins */
                                 arg_wipe_slots_scope = WIPE_EMPTY_PASSPHRASE;
-                } else if (streq(slot, "password"))
-                        arg_wipe_slots_mask |= 1U << ENROLL_PASSWORD;
-                else if (streq(slot, "recovery"))
-                        arg_wipe_slots_mask |= 1U << ENROLL_RECOVERY;
-                else if (streq(slot, "pkcs11"))
-                        arg_wipe_slots_mask |= 1U << ENROLL_PKCS11;
-                else if (streq(slot, "fido2"))
-                        arg_wipe_slots_mask |= 1U << ENROLL_FIDO2;
-                else if (streq(slot, "tpm2"))
-                        arg_wipe_slots_mask |= 1U << ENROLL_TPM2;
+                } else if ((mask = enroll_type_mask_from_string(slot)) >= 0)
+                        arg_wipe_slots_mask |= mask;
                 else {
                         unsigned n;
 
@@ -249,15 +296,34 @@ static int parse_wipe_slot(const char *arg) {
         }
 }
 
-static int help(void) {
-        _cleanup_free_ char *link = NULL;
+static int parse_prompt_suppress(const char *arg) {
         int r;
 
-        pager_open(arg_pager_flags);
+        assert(arg);
 
-        r = terminal_urlify_man("systemd-cryptenroll", "1", &link);
-        if (r < 0)
-                return log_oom();
+        /* Parses a comma-separated list of the slot types the --firstboot wizard knows how to enroll. If a
+         * slot of any listed type already exists on the volume, the wizard does nothing. */
+
+        for (const char *p = arg;;) {
+                _cleanup_free_ char *type = NULL;
+                int mask;
+
+                r = extract_first_word(&p, &type, ",", EXTRACT_DONT_COALESCE_SEPARATORS);
+                if (r == 0)
+                        return 0;
+                if (r < 0)
+                        return log_error_errno(r, "Failed to parse type list: %s", arg);
+
+                mask = enroll_type_mask_from_string(type);
+                if (mask < 0)
+                        return log_error_errno(mask, "Unknown slot type: %s", type);
+
+                arg_prompt_suppress_mask |= mask;
+        }
+}
+
+static int help(void) {
+        int r;
 
         static const char* const groups[] = {
                 NULL,
@@ -279,21 +345,20 @@ static int help(void) {
 
         (void) table_sync_column_widths(0, tables[0], tables[1], tables[2], tables[3], tables[4], tables[5]);
 
-        printf("%s [OPTIONS...] [BLOCK-DEVICE]\n\n"
-               "%sEnroll a security token or authentication credential to a LUKS volume.%s\n",
-               program_invocation_short_name,
-               ansi_highlight(),
-               ansi_normal());
+        pager_open(arg_pager_flags);
+
+        help_cmdline("[OPTIONS...] [BLOCK-DEVICE]");
+        help_abstract("Enroll a security token or authentication credential to a LUKS volume.");
 
         for (size_t i = 0; i < ELEMENTSOF(groups); i++) {
-                printf("\n%s%s:%s\n", ansi_underline(), groups[i] ?: "Options", ansi_normal());
+                help_section(groups[i] ?: "Options");
 
                 r = table_print_or_warn(tables[i]);
                 if (r < 0)
                         return r;
         }
 
-        printf("\nSee the %s for details.\n", link);
+        help_man_page_reference("systemd-cryptenroll", "1");
         return 0;
 }
 
@@ -332,6 +397,32 @@ static int parse_argv(int argc, char *argv[]) {
                                 return r;
                         break;
 
+                OPTION_LONG("firstboot", NULL,
+                            "Interactively enroll a credential (first-boot wizard)"):
+                        arg_firstboot = true;
+                        break;
+
+                OPTION_LONG("prompt-suppress", "TYPE1,TYPE2,…",
+                            "Skip the --firstboot wizard if a slot of any listed type exists"):
+                        r = parse_prompt_suppress(opts.arg);
+                        if (r < 0)
+                                return r;
+                        break;
+
+                OPTION_LONG("chrome", "BOOL",
+                            "In first-boot mode: if false don't show colour bar at top and bottom of terminal"):
+                        r = parse_boolean_argument("--chrome=", opts.arg, &arg_chrome);
+                        if (r < 0)
+                                return r;
+                        break;
+
+                OPTION_LONG("mute-console", "BOOL",
+                            "In first-boot mode, tell kernel/PID 1 to not write to the console while running"):
+                        r = parse_boolean_argument("--mute-console=", opts.arg, &arg_mute_console);
+                        if (r < 0)
+                                return r;
+                        break;
+
                 OPTION_GROUP("Unlocking"): {}
 
                 OPTION_LONG("unlock-empty", NULL, "Use an empty password to unlock the volume"):
@@ -625,6 +716,17 @@ static int parse_argv(int argc, char *argv[]) {
         if (option_parser_get_n_args(&opts) > 1)
                 return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Too many arguments, refusing.");
 
+        if (arg_firstboot) {
+                if (arg_enroll_type >= 0)
+                        return log_error_errno(SYNTHETIC_ERRNO(EINVAL),
+                                               "--firstboot may not be combined with an explicit enrollment type, refusing.");
+                if (wipe_requested())
+                        return log_error_errno(SYNTHETIC_ERRNO(EINVAL),
+                                               "--firstboot may not be combined with --wipe-slot=, refusing.");
+        } else if (arg_prompt_suppress_mask != 0)
+                return log_error_errno(SYNTHETIC_ERRNO(EINVAL),
+                                       "--prompt-suppress= is only useful together with --firstboot, refusing.");
+
         const char *arg = option_parser_get_arg(&opts, 0);
         if (arg)
                 r = parse_path_argument(arg, false, &arg_node);
@@ -932,8 +1034,27 @@ static int run(int argc, char *argv[]) {
         if (r < 0)
                 return r;
 
-        /* If we are called without anything to enroll, we just need the LUKS device, not the volume key. */
-        if (c.enroll_type < 0) {
+        _cleanup_(sd_varlink_flush_close_unrefp) sd_varlink *mute_console_link = NULL;
+
+        /* Ensure the interactive chrome (drawn by cryptenroll_run_interactive() in --firstboot mode) is
+         * always torn down on exit; chrome_hide() is a no-op if no chrome was shown. */
+        DEFER_VOID_CALL(chrome_hide);
+
+        if (arg_firstboot) {
+                assert(c.enroll_type < 0);
+
+                r = cryptenroll_run_interactive(
+                                &c,
+                                arg_prompt_suppress_mask,
+                                arg_chrome,
+                                arg_mute_console ? &mute_console_link : NULL);
+                if (r <= 0)
+                        return r;
+
+                assert(c.enroll_type >= 0);
+
+        } else if (c.enroll_type < 0) {
+                /* If we are called without anything to enroll, we just need the LUKS device, not the volume key. */
                 r = prepare_luks(&c, &cd, /* ret_volume_key= */ NULL);
                 if (r < 0)
                         return r;
@@ -948,19 +1069,27 @@ static int run(int argc, char *argv[]) {
 
         r = prepare_luks(&c, &cd, &vk);
         if (r < 0)
-                return r;
+                goto finish;
 
         slot = enroll_now(&c, cd, &vk, /* ret_recovery_key= */ NULL);
-        if (slot < 0)
-                return slot;
+        if (slot < 0) {
+                r = slot;
+                goto finish;
+        }
 
         /* After we completed enrolling, remove user selected slots (keeping the one we just added) */
         c.wipe_except_slot = slot;
         r = wipe_slots(&c, cd, /* ret_wiped_slots= */ NULL, /* ret_n_wiped_slots= */ NULL);
         if (r < 0)
-                return r;
+                goto finish;
 
-        return 0;
+        r = 0;
+
+finish:
+        if (arg_firstboot)
+                (void) any_key_to_proceed();
+
+        return r;
 }
 
 DEFINE_MAIN_FUNCTION(run);
index 990b3dbc17ed73f312354f2a2480ad95b6bd4e36..3fbb5bf080bcc0e188c496a2d0a4e60a4d633957 100644 (file)
@@ -7,6 +7,7 @@ endif
 systemd_cryptenroll_sources = files(
         'cryptenroll.c',
         'cryptenroll-fido2.c',
+        'cryptenroll-interactive.c',
         'cryptenroll-list.c',
         'cryptenroll-password.c',
         'cryptenroll-pkcs11.c',
index a7c776d0ce3a1f0b2d1cae8a0575eefeac8be3e8..4a0d7e6d6475429ae1d4a1ce9e66b14fa326f13c 100644 (file)
@@ -1319,6 +1319,109 @@ finish:
 #endif
 }
 
+void fido2_device_info_done(Fido2DeviceInfo *d) {
+        assert(d);
+
+        d->path = mfree(d->path);
+        d->manufacturer = mfree(d->manufacturer);
+        d->product = mfree(d->product);
+}
+
+void fido2_device_info_free_many(Fido2DeviceInfo *a, size_t n) {
+        FOREACH_ARRAY(i, a, n)
+                fido2_device_info_done(i);
+
+        free(a);
+}
+
+int fido2_enumerate_devices(Fido2DeviceInfo **ret, size_t *ret_n) {
+#if HAVE_LIBFIDO2
+        Fido2DeviceInfo *devices = NULL;
+        size_t n_devices = 0, allocated = 64, found = 0;
+        fido_dev_info_t *di = NULL;
+        int r;
+
+        CLEANUP_ARRAY(devices, n_devices, fido2_device_info_free_many);
+
+        assert(ret);
+        assert(ret_n);
+
+        r = dlopen_libfido2(LOG_ERR);
+        if (r < 0)
+                return r;
+
+        di = sym_fido_dev_info_new(allocated);
+        if (!di)
+                return log_oom();
+
+        r = sym_fido_dev_info_manifest(di, allocated, &found);
+        if (r == FIDO_ERR_INTERNAL || (r == FIDO_OK && found == 0)) {
+                /* The library returns FIDO_ERR_INTERNAL when no devices are found. I wish it wouldn't. */
+                r = 0;
+                goto finish;
+        }
+        if (r != FIDO_OK) {
+                r = log_error_errno(SYNTHETIC_ERRNO(EIO), "Failed to enumerate FIDO2 devices: %s", sym_fido_strerr(r));
+                goto finish;
+        }
+
+        for (size_t i = 0; i < found; i++) {
+                bool has_rk, has_client_pin, has_up, has_uv, has_always_uv;
+                const fido_dev_info_t *entry;
+                const char *path;
+
+                entry = sym_fido_dev_info_ptr(di, i);
+                if (!entry) {
+                        r = log_error_errno(SYNTHETIC_ERRNO(EIO),
+                                            "Failed to get device information for FIDO device %zu.", i);
+                        goto finish;
+                }
+
+                path = sym_fido_dev_info_path(entry);
+
+                r = check_device_is_fido2_with_hmac_secret(path, &has_rk, &has_client_pin, &has_up, &has_uv, &has_always_uv);
+                if (r < 0)
+                        goto finish;
+                if (r == 0) /* Not a FIDO2 device suitable for enrollment, skip it */
+                        continue;
+
+                if (!GREEDY_REALLOC(devices, n_devices + 1)) {
+                        r = log_oom();
+                        goto finish;
+                }
+
+                Fido2DeviceInfo *d = devices + n_devices;
+                *d = (Fido2DeviceInfo) {};
+
+                /* The manufacturer / product strings are optional (NULL when the device doesn't report
+                 * them), but a failure to duplicate any string is a genuine OOM and thus fatal. */
+                if (strdup_to(&d->path, path) < 0 ||
+                    strdup_to(&d->manufacturer, empty_to_null(sym_fido_dev_info_manufacturer_string(entry))) < 0 ||
+                    strdup_to(&d->product, empty_to_null(sym_fido_dev_info_product_string(entry))) < 0) {
+                        fido2_device_info_done(d);
+                        r = log_oom();
+                        goto finish;
+                }
+
+                n_devices++;
+        }
+
+        r = 0;
+
+finish:
+        sym_fido_dev_info_free(&di, allocated);
+        if (r < 0)
+                return r;
+
+        *ret = TAKE_PTR(devices);
+        *ret_n = n_devices;
+        return 0;
+#else
+        return log_error_errno(SYNTHETIC_ERRNO(EOPNOTSUPP),
+                               "FIDO2 tokens not supported on this build.");
+#endif
+}
+
 int fido2_find_device_auto(char **ret) {
 #if HAVE_LIBFIDO2
         _cleanup_free_ char *copy = NULL;
index 6b23b62688bd871d4713f0400546f41c984e2eb9..12a0201332fa493f13427de707b2aa6f51878c98 100644 (file)
@@ -137,4 +137,17 @@ static inline int parse_fido2_algorithm(const char *s, int *ret) {
 int fido2_list_devices(void);
 int fido2_find_device_auto(char **ret);
 
+typedef struct Fido2DeviceInfo {
+        char *path;
+        char *manufacturer;     /* may be NULL if the device doesn't report it */
+        char *product;          /* may be NULL if the device doesn't report it */
+} Fido2DeviceInfo;
+
+void fido2_device_info_done(Fido2DeviceInfo *d);
+void fido2_device_info_free_many(Fido2DeviceInfo *a, size_t n);
+
+/* Enumerates the FIDO2 tokens that implement the 'hmac-secret' extension (i.e. are suitable for
+ * enrollment), returning a newly allocated array. */
+int fido2_enumerate_devices(Fido2DeviceInfo **ret, size_t *ret_n);
+
 int fido2_have_device(const char *device);
index f8134262160fa748aa75afeb614b42ff64cb1155..60ce3bf0cadab10aa3417dff56cc722b10d65bf6 100644 (file)
@@ -347,6 +347,11 @@ units = [
           'file' : 'systemd-cryptenroll@.service',
           'conditions' : ['HAVE_LIBCRYPTSETUP'],
         },
+        {
+          'file' : 'systemd-cryptenroll-firstboot.service',
+          'conditions' : ['HAVE_LIBCRYPTSETUP', 'ENABLE_INITRD'],
+          'symlinks' : ['initrd.target.wants/'],
+        },
         { 'file' : 'systemd-exit.service' },
         { 'file' : 'systemd-factory-reset@.service.in' },
         {
diff --git a/units/systemd-cryptenroll-firstboot.service b/units/systemd-cryptenroll-firstboot.service
new file mode 100644 (file)
index 0000000..ccd69ef
--- /dev/null
@@ -0,0 +1,41 @@
+#  SPDX-License-Identifier: LGPL-2.1-or-later
+#
+#  This file is part of systemd.
+#
+#  systemd is free software; you can redistribute it and/or modify it
+#  under the terms of the GNU Lesser General Public License as published by
+#  the Free Software Foundation; either version 2.1 of the License, or
+#  (at your option) any later version.
+
+# Runs from the initrd: once systemd-repart has created/grown the partitions
+# (including any encrypted ones) on first boot, but before we transition into
+# the host, give the user a chance to interactively enroll a disk encryption
+# credential.
+
+[Unit]
+Description=Interactive Disk Encryption Setup
+Documentation=man:systemd-cryptenroll(1)
+
+ConditionPathExists=/etc/initrd-release
+
+# We want a first boot check, but in the initrd that's not a defined
+# state. Hence let's look into the mounted image directly for the primary
+# indicator for the first boot state
+ConditionFileNotEmpty=!/sysroot/etc/machine-id
+ConditionPathIsEncrypted=/sysroot/var
+
+DefaultDependencies=no
+After=systemd-repart.service systemd-vconsole-setup.service systemd-mute-console.socket initrd-fs.target
+Conflicts=shutdown.target initrd-switch-root.target
+Before=shutdown.target initrd-switch-root.target initrd.target
+
+[Service]
+Type=oneshot
+RemainAfterExit=yes
+# Ignore failures: there might not be an encrypted volume to enroll into, or the
+# user might decline, neither of which should hold up the boot.
+ExecStart=-systemd-cryptenroll --firstboot --prompt-suppress=password,recovery,fido2 --unlock-headless --mute-console=yes
+StandardOutput=tty
+StandardInput=tty
+StandardError=tty
+TTYReset=yes