]> git.ipfire.org Git - thirdparty/systemd.git/commitdiff
pcrextend: add support for measuring a user record, to be executed on first login...
authorLennart Poettering <lennart@amutable.com>
Thu, 21 May 2026 12:47:59 +0000 (14:47 +0200)
committerLennart Poettering <lennart@amutable.com>
Fri, 22 May 2026 10:15:12 +0000 (12:15 +0200)
This is supposed to be useful to mark an interactive user login as a
"break glass" event in the measurement logs, i.e. as in many typically
headless scenerios this indicates debug access or similar.

man/rules/meson.build
man/systemd-pcrphase.service.xml
src/pcrextend/pcrextend.c
src/shared/pcrextend-util.c
src/shared/pcrextend-util.h
src/shared/tpm2-util.c
src/shared/tpm2-util.h
src/tpm2-setup/meson.build
src/tpm2-setup/nvpcr/login.nvpcr.in [new file with mode: 0644]
units/meson.build
units/systemd-pcrlogin@.service.in [new file with mode: 0644]

index e0a99b1a21c9e06e2cc38170af85031793b75446..6fd43ad4c33f9f79bd3bf278d131f1022a399bc5 100644 (file)
@@ -1147,6 +1147,7 @@ manpages = [
   ['systemd-pcrextend',
    'systemd-pcrfs-root.service',
    'systemd-pcrfs@.service',
+   'systemd-pcrlogin@.service',
    'systemd-pcrmachine.service',
    'systemd-pcrnvdone.service',
    'systemd-pcrosseparator.service',
index 3e9afe7caa346ea0de200a23a4d6d6292c18a3d3..8dbe8e4531285726caf6d92e9f609d7b339e6669 100644 (file)
     <refname>systemd-pcrmachine.service</refname>
     <refname>systemd-pcrosseparator.service</refname>
     <refname>systemd-pcrproduct.service</refname>
+    <refname>systemd-pcrlogin@.service</refname>
     <refname>systemd-pcrfs-root.service</refname>
     <refname>systemd-pcrfs@.service</refname>
     <refname>systemd-pcrnvdone.service</refname>
     <refname>systemd-pcrextend</refname>
-    <refpurpose>Measure boot phases, machine ID, product UUID and file system identity into TPM PCRs and NvPCRs</refpurpose>
+    <refpurpose>Measure boot phases, machine ID, product UUID, user records and file system identity into TPM PCRs and NvPCRs</refpurpose>
   </refnamediv>
 
   <refsynopsisdiv>
@@ -37,6 +38,7 @@
     <para><filename>systemd-pcrmachine.service</filename></para>
     <para><filename>systemd-pcrosseparator.service</filename></para>
     <para><filename>systemd-pcrproduct.service</filename></para>
+    <para><filename>systemd-pcrlogin@.service</filename></para>
     <para><filename>systemd-pcrfs-root.service</filename></para>
     <para><filename>systemd-pcrfs@.service</filename></para>
     <para><filename>systemd-pcrnvdone.service</filename></para>
     product UUID (as provided by one of SMBIOS, Devicetree, …) into a NvPCR named
     <literal>hardware</literal>.</para>
 
+    <para><filename>systemd-pcrlogin@.service</filename> is a template service that measures the (reduced,
+    canonical JSON) user record of the user indicated by its instance identifier (a UID) into a NvPCR named
+    <literal>login</literal>. It is started by
+    <citerefentry><refentrytitle>systemd-logind.service</refentrytitle><manvolnum>8</manvolnum></citerefentry>
+    on the user's first login of the current boot, much like
+    <citerefentry><refentrytitle>user-runtime-dir@.service</refentrytitle><manvolnum>5</manvolnum></citerefentry>.
+    Unlike the latter it is intentionally never stopped again: it is a <varname>Type=oneshot</varname> service
+    with <varname>RemainAfterExit=yes</varname> in <filename>system.slice</filename>, so each user is measured
+    exactly once per boot regardless of how often they log in and out (NvPCR extensions form a hash chain, so
+    measuring the same record twice would otherwise make the NvPCR value unpredictable). The resulting
+    <literal>login</literal> NvPCR (together with the
+    <filename>/run/log/systemd/tpm2-measure.log</filename> event log) thus provides a TPM-backed,
+    append-only record of which user identities were activated during the current boot.</para>
+
     <para><filename>systemd-pcrnvdone.service</filename> is a system service that measures a separator event
     into PCR 9 once all NvPCRs have completed initialization.</para>
 
         <xi:include href="version-info.xml" xpointer="v259"/></listitem>
       </varlistentry>
 
+      <varlistentry>
+        <term><option>--login=</option></term>
+
+        <listitem><para>Instead of measuring a word specified on the command line into PCR 11, measure the
+        user record of the specified user into an NvPCR named <literal>login</literal>. The parameter must be a
+        user name or numeric UID. The record is reduced to its <literal>regular</literal>,
+        <literal>perMachine</literal> and <literal>binding</literal> sections (i.e. the privileged, secret,
+        status and signature sections are stripped, so that for example password hash changes do not perturb
+        the NvPCR), normalized and serialized to canonical JSON before measurement. This is primarily used by
+        <filename>systemd-pcrlogin@.service</filename>.</para>
+
+        <xi:include href="version-info.xml" xpointer="v261"/></listitem>
+      </varlistentry>
+
       <varlistentry>
         <term><option>--file-system=</option></term>
 
 
         <listitem><para>Set the event log event type for this measurement. Pass <literal>help</literal> for a
         list of currently defined identifiers. Defaults to an appropriate value for
-        <option>--machine-id</option>, <option>--product-id</option>, <option>--file-system=</option>, and
-        otherwise to <literal>phase</literal>.</para>
+        <option>--machine-id</option>, <option>--product-id</option>, <option>--file-system=</option>,
+        <option>--login=</option>, and otherwise to <literal>phase</literal>.</para>
 
         <xi:include href="version-info.xml" xpointer="v259"/></listitem>
       </varlistentry>
index 5109ebb897efc0e6932fb1e393738f9bf35c8ecb..ac432aaac579b1ff5ad16327045c6ad9419f1ce5 100644 (file)
@@ -21,6 +21,8 @@
 #include "strv.h"
 #include "tpm2-pcr.h"
 #include "tpm2-util.h"
+#include "user-record.h"
+#include "userdb.h"
 #include "varlink-io.systemd.PCRExtend.h"
 #include "varlink-util.h"
 
@@ -30,6 +32,7 @@ static char **arg_banks = NULL;
 static char *arg_file_system = NULL;
 static bool arg_machine_id = false;
 static bool arg_product_id = false;
+static UserRecord *arg_login = NULL;
 static uint32_t arg_pcr_mask = 0;
 static char *arg_nvpcr_name = NULL;
 static bool arg_varlink = false;
@@ -40,6 +43,7 @@ STATIC_DESTRUCTOR_REGISTER(arg_banks, strv_freep);
 STATIC_DESTRUCTOR_REGISTER(arg_tpm2_device, freep);
 STATIC_DESTRUCTOR_REGISTER(arg_file_system, freep);
 STATIC_DESTRUCTOR_REGISTER(arg_nvpcr_name, freep);
+STATIC_DESTRUCTOR_REGISTER(arg_login, user_record_unrefp);
 
 #define EXTENSION_STRING_SAFE_LIMIT 1024
 
@@ -55,7 +59,8 @@ static int help(void) {
         help_cmdline("[OPTIONS...] --file-system=PATH");
         help_cmdline("[OPTIONS...] --machine-id");
         help_cmdline("[OPTIONS...] --product-id");
-        help_abstract("Extend a TPM2 PCR with boot phase, machine ID, or file system ID.");
+        help_cmdline("[OPTIONS...] --login=UID|USER");
+        help_abstract("Extend a TPM2 PCR with boot phase, machine ID, file system ID or user record.");
 
         help_section("Options");
         r = table_print_or_warn(options);
@@ -158,6 +163,19 @@ static int parse_argv(int argc, char *argv[], char ***ret_args) {
                         arg_product_id = true;
                         break;
 
+                OPTION_LONG("login", "UID|USER",
+                            "Measure a user's record into NvPCR 'login'"): {
+                        _cleanup_(user_record_unrefp) UserRecord *ur = NULL;
+
+                        r = userdb_by_name(opts.arg, /* match= */ NULL, USERDB_PARSE_NUMERIC|USERDB_SUPPRESS_SHADOW, &ur);
+                        if (r < 0)
+                                return log_error_errno(r, "Failed to look up user '%s': %m", opts.arg);
+
+                        user_record_unref(arg_login);
+                        arg_login = TAKE_PTR(ur);
+                        break;
+                }
+
                 OPTION_LONG("early", NULL,
                             "Run in early boot mode, without access to /var/"):
                         arg_early = true;
@@ -174,8 +192,8 @@ static int parse_argv(int argc, char *argv[], char ***ret_args) {
                         break;
                 }
 
-        if (!!arg_file_system + arg_machine_id + arg_product_id > 1)
-                return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "--file-system=, --machine-id, --product-id may not be combined.");
+        if (!!arg_file_system + arg_machine_id + arg_product_id + !!arg_login > 1)
+                return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "--file-system=, --machine-id, --product-id, --login= may not be combined.");
 
         if (arg_pcr_mask != 0 && arg_nvpcr_name)
                 return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "--pcr= and --nvpcr= may not be combined.");
@@ -188,10 +206,13 @@ static int parse_argv(int argc, char *argv[], char ***ret_args) {
         else if (arg_pcr_mask == 0 && !arg_nvpcr_name) {
                 arg_pcr_mask =
                         (arg_file_system || arg_machine_id) ? INDEX_TO_MASK(uint32_t, TPM2_PCR_SYSTEM_IDENTITY) : /* → PCR 15 */
-                                           !arg_product_id  ? INDEX_TO_MASK(uint32_t, TPM2_PCR_KERNEL_BOOT) :     /* → PCR 11 */
-                                                              0;
+                              (arg_product_id || arg_login) ? 0 :                                                 /* → NvPCR */
+                                                              INDEX_TO_MASK(uint32_t, TPM2_PCR_KERNEL_BOOT);      /* → PCR 11 */
 
-                r = free_and_strdup_warn(&arg_nvpcr_name, arg_product_id ? "hardware" : NULL);
+                r = free_and_strdup_warn(&arg_nvpcr_name,
+                                         arg_product_id  ? "hardware" :
+                                         arg_login       ? "login" :
+                                                           NULL);
                 if (r < 0)
                         return r;
         }
@@ -485,6 +506,17 @@ static int run(int argc, char *argv[]) {
                         return r;
 
                 event = TPM2_EVENT_PRODUCT_ID;
+
+        } else if (arg_login) {
+
+                if (n_args != 0)
+                        return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Expected no argument.");
+
+                r = pcrextend_login_word(arg_login, &word);
+                if (r < 0)
+                        return r;
+
+                event = TPM2_EVENT_LOGIN;
         } else {
                 if (n_args != 1)
                         return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Expected a single argument.");
index 79efdc4574e2f55c61aa25ee7958917c39582cba..cf6eb7d1a56f711efe02320dec8967c3701c81a8 100644 (file)
@@ -2,6 +2,7 @@
 
 #include "sd-device.h"
 #include "sd-id128.h"
+#include "sd-json.h"
 #include "sd-varlink.h"
 
 #include "alloc-util.h"
@@ -22,6 +23,7 @@
 #include "string-util.h"
 #include "strv.h"
 #include "tpm2-pcr.h"
+#include "user-record.h"
 
 static int device_get_file_system_word(
                 sd_device *d,
@@ -187,6 +189,58 @@ int pcrextend_product_id_word(char **ret) {
         return 0;
 }
 
+int pcrextend_login_word(UserRecord *ur, char **ret) {
+        int r;
+
+        assert(ur);
+        assert(ret);
+
+        /* Reduce the user record to the sections that make up its stable, host-specific identity, and turn
+         * that into a word to measure into the 'login' NvPCR. We deliberately keep the 'regular',
+         * 'perMachine' and 'binding' sections (the portable identity plus how it is realized on *this*
+         * host), but drop 'privileged' (so password hash churn doesn't perturb the NvPCR), 'secret' and
+         * 'status' (transient) and 'signature' (so the scheme is identical for signed and unsigned records).
+         * Only 'regular' is required, the others are merely allowed, so plain NSS users reduce cleanly. */
+        UserRecordLoadFlags mask =
+                USER_RECORD_REQUIRE_REGULAR |
+                USER_RECORD_ALLOW_PER_MACHINE |
+                USER_RECORD_ALLOW_BINDING |
+                USER_RECORD_STRIP_PRIVILEGED |
+                USER_RECORD_STRIP_SECRET |
+                USER_RECORD_STRIP_STATUS |
+                USER_RECORD_STRIP_SIGNATURE |
+                USER_RECORD_PERMISSIVE;
+
+        _cleanup_(sd_json_variant_unrefp) sd_json_variant *v = NULL;
+        r = user_group_record_mangle(ur->json, mask, &v, /* ret_mask= */ NULL);
+        if (r < 0)
+                return log_error_errno(r, "Failed to reduce user record of '%s': %m", ur->user_name);
+
+        /* Normalize so the serialization is canonical (stable key order) regardless of how the record was
+         * assembled by the various userdb backends. */
+        r = sd_json_variant_normalize(&v);
+        if (r < 0)
+                return log_error_errno(r, "Failed to normalize user record of '%s': %m", ur->user_name);
+
+        /* Format to compact, single-line JSON (no SD_JSON_FORMAT_NEWLINE), so the measured word stays on one
+         * line and the colon-separated word structure is unambiguous. */
+        _cleanup_free_ char *text = NULL;
+        r = sd_json_variant_format(v, /* flags= */ 0, &text);
+        if (r < 0)
+                return log_error_errno(r, "Failed to format user record of '%s': %m", ur->user_name);
+
+        _cleanup_free_ char *name_escaped = xescape(ur->user_name, ":"); /* Avoid ambiguity around ":" */
+        if (!name_escaped)
+                return log_oom();
+
+        _cleanup_free_ char *word = strjoin("login:", name_escaped, ":", text);
+        if (!word)
+                return log_oom();
+
+        *ret = TAKE_PTR(word);
+        return 0;
+}
+
 int pcrextend_verity_word(
                 const char *name,
                 const struct iovec *root_hash,
index eadc2d5cffc980814ceda96bd4829709a3a305f1..c753c265683bff15c71bb6237e337a43ee110c7c 100644 (file)
@@ -3,11 +3,14 @@
 
 #include <sys/uio.h>
 
+#include "shared-forward.h"
+
 int pcrextend_file_system_word(const char *path, char **ret, char **ret_normalized_path);
 int pcrextend_machine_id_word(char **ret);
 int pcrextend_product_id_word(char **ret);
 int pcrextend_verity_word(const char *name, const struct iovec *root_hash, const struct iovec *root_hash_sig, char **ret);
 int pcrextend_imds_userdata_word(const struct iovec *data, char **ret);
+int pcrextend_login_word(UserRecord *ur, char **ret);
 
 int pcrextend_verity_now(const char *name, const struct iovec *root_hash, const struct iovec *root_hash_sig);
 int pcrextend_imds_userdata_now(const struct iovec *data);
index 26ce8d72464bff41c9bc39e7a8e0bfeac56232fd..3f29730c7f8cbc7cb7faa351f1dca7bddd6c658a 100644 (file)
@@ -6702,6 +6702,7 @@ static const char* tpm2_userspace_event_type_table[_TPM2_USERSPACE_EVENT_TYPE_MA
         [TPM2_EVENT_DM_VERITY]       = "dm-verity",
         [TPM2_EVENT_IMDS_USERDATA]   = "imds-userdata",
         [TPM2_EVENT_OS_SEPARATOR]    = "os-separator",
+        [TPM2_EVENT_LOGIN]           = "login",
 };
 
 DEFINE_STRING_TABLE_LOOKUP(tpm2_userspace_event_type, Tpm2UserspaceEventType);
index bf2321a514aab048bff743450d7c9c77c1c6d093..bc26a16947ad10c50be76653b7bd6145ceb157f6 100644 (file)
@@ -151,6 +151,7 @@ typedef enum Tpm2UserspaceEventType {
         TPM2_EVENT_DM_VERITY,
         TPM2_EVENT_IMDS_USERDATA,
         TPM2_EVENT_OS_SEPARATOR,
+        TPM2_EVENT_LOGIN,
         _TPM2_USERSPACE_EVENT_TYPE_MAX,
         _TPM2_USERSPACE_EVENT_TYPE_INVALID = -EINVAL,
 } Tpm2UserspaceEventType;
index e87a13d8e66c9316de525b277cba933c6bc536cc..55f0ce26d31cb972bb749ae51e0f82bf36ce729e 100644 (file)
@@ -45,6 +45,7 @@ executables += [
 if conf.get('ENABLE_BOOTLOADER') == 1 and conf.get('HAVE_OPENSSL') == 1 and conf.get('HAVE_TPM2') == 1
         nvpcrs = [ 'cryptsetup',
                    'hardware',
+                   'login',
                    'verity']
         foreach n : nvpcrs
                 custom_target(
diff --git a/src/tpm2-setup/nvpcr/login.nvpcr.in b/src/tpm2-setup/nvpcr/login.nvpcr.in
new file mode 100644 (file)
index 0000000..babad7a
--- /dev/null
@@ -0,0 +1,6 @@
+{
+    "name" : "login",
+    "algorithm" : "sha256",
+    "nvIndex" : {{TPM2_NVPCR_BASE + 3}},
+    "priority" : 800
+}
index c78835aa70e315e15b96a1b9ff737cea9531dc0e..de7a796a1e4298b53dadf5dd6bd7fe267e5266c5 100644 (file)
@@ -604,6 +604,10 @@ units = [
           'conditions' : ['ENABLE_BOOTLOADER', 'HAVE_OPENSSL', 'HAVE_TPM2'],
           'symlinks' : ['sysinit.target.wants/'],
         },
+        {
+          'file' : 'systemd-pcrlogin@.service.in',
+          'conditions' : ['ENABLE_BOOTLOADER', 'HAVE_OPENSSL', 'HAVE_TPM2'],
+        },
         {
           'file' : 'systemd-pcrphase-initrd.service.in',
           'conditions' : ['ENABLE_BOOTLOADER', 'HAVE_OPENSSL', 'HAVE_TPM2', 'ENABLE_INITRD'],
diff --git a/units/systemd-pcrlogin@.service.in b/units/systemd-pcrlogin@.service.in
new file mode 100644 (file)
index 0000000..dbdf488
--- /dev/null
@@ -0,0 +1,24 @@
+#  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.
+
+[Unit]
+Description=TPM NvPCR Measurement of User Record for UID %i
+Documentation=man:systemd-pcrlogin@.service(8)
+DefaultDependencies=no
+Conflicts=shutdown.target
+After=tpm2.target systemd-pcrnvdone.service
+Before=shutdown.target
+RequiresMountsFor=/var/lib/systemd/nvpcr
+ConditionPathExists=!/etc/initrd-release
+ConditionSecurity=measured-os
+
+[Service]
+Type=oneshot
+RemainAfterExit=yes
+ExecStart={{LIBEXECDIR}}/systemd-pcrextend --graceful --login=%i