diff --git a/TODO.md b/TODO.md
index 8c59952ad9..4e5443d049 100644
--- a/TODO.md
+++ b/TODO.md
@@ -1737,8 +1737,6 @@ SPDX-License-Identifier: LGPL-2.1-or-later
- maybe: in PID1, when we detect we run in an initrd, make superblock read-only
early on, but provide opt-out via kernel cmdline.
-- measure all log-in attempts into a new nvpcr
-
- measure GPT and LUKS headers somewhere when we use them (i.e. in
systemd-gpt-auto-generator/systemd-repart and in systemd-cryptsetup?)
@@ -1902,8 +1900,6 @@ SPDX-License-Identifier: LGPL-2.1-or-later
keyring, so that the kernel does this validation for us for verity and kernel
modules
-- on first login of a user, measure its identity to some nvpcr
-
- on shutdown: move utmp, wall, audit logic all into PID 1 (or logind?)
- once swtpm's sd_notify() support has landed in the distributions, remove the
diff --git a/docs/TPM2_PCR_MEASUREMENTS.md b/docs/TPM2_PCR_MEASUREMENTS.md
index 8bdcd6979e..8045cbe4cd 100644
--- a/docs/TPM2_PCR_MEASUREMENTS.md
+++ b/docs/TPM2_PCR_MEASUREMENTS.md
@@ -322,6 +322,23 @@ colon-separated strings, identifying the file system type, UUID, label as well
as the GPT partition entry UUID, entry type UUID and entry label (in UTF-8,
without trailing NUL bytes).
+### NvPCR `login` (base+3), user logins
+
+The `systemd-pcrlogin@.service` service (a per-UID template unit started by
+`systemd-logind.service` on a user's first login of the current boot) will
+measure that user's record into the `login` NvPCR. Each user is measured exactly
+once per boot (the unit is `Type=oneshot`/`RemainAfterExit=yes` and is never
+stopped again), so the NvPCR forms an append-only record of which user
+identities were activated during the current boot. Note that its value is
+inherently dynamic: it depends on *which* users log in and *in which order*.
+
+→ **Measured hash** covers the string "login:", suffixed by the (escaped)
+user name, a colon, and the user's record reduced to its `regular`, `perMachine`
+and `binding` sections (i.e. with the `privileged`, `secret`, `status` and
+`signature` sections stripped), normalized and serialized to canonical,
+single-line JSON. Example string:
+`login:lennart:{"userName":"lennart","uid":1000,…}`.
+
### PCR 9, NvPCR initialization separator
After completion of `systemd-tpm2-setup.service` (which initializes all NvPCRs
diff --git a/man/rules/meson.build b/man/rules/meson.build
index e0a99b1a21..6fd43ad4c3 100644
--- a/man/rules/meson.build
+++ b/man/rules/meson.build
@@ -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',
diff --git a/man/systemd-pcrphase.service.xml b/man/systemd-pcrphase.service.xml
index 3e9afe7caa..8dbe8e4531 100644
--- a/man/systemd-pcrphase.service.xml
+++ b/man/systemd-pcrphase.service.xml
@@ -23,11 +23,12 @@
systemd-pcrmachine.servicesystemd-pcrosseparator.servicesystemd-pcrproduct.service
+ systemd-pcrlogin@.servicesystemd-pcrfs-root.servicesystemd-pcrfs@.servicesystemd-pcrnvdone.servicesystemd-pcrextend
- Measure boot phases, machine ID, product UUID and file system identity into TPM PCRs and NvPCRs
+ Measure boot phases, machine ID, product UUID, user records and file system identity into TPM PCRs and NvPCRs
@@ -37,6 +38,7 @@
systemd-pcrmachine.servicesystemd-pcrosseparator.servicesystemd-pcrproduct.service
+ systemd-pcrlogin@.servicesystemd-pcrfs-root.servicesystemd-pcrfs@.servicesystemd-pcrnvdone.service
@@ -64,6 +66,20 @@
product UUID (as provided by one of SMBIOS, Devicetree, …) into a NvPCR named
hardware.
+ systemd-pcrlogin@.service 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
+ login. It is started by
+ systemd-logind.service8
+ on the user's first login of the current boot, much like
+ user-runtime-dir@.service5.
+ Unlike the latter it is intentionally never stopped again: it is a Type=oneshot service
+ with RemainAfterExit=yes in system.slice, 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
+ login NvPCR (together with the
+ /run/log/systemd/tpm2-measure.log event log) thus provides a TPM-backed,
+ append-only record of which user identities were activated during the current boot.
+
systemd-pcrnvdone.service is a system service that measures a separator event
into PCR 9 once all NvPCRs have completed initialization.
@@ -244,6 +260,20 @@
+
+
+
+ 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 login. The parameter must be a
+ user name or numeric UID. The record is reduced to its regular,
+ perMachine and binding 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
+ systemd-pcrlogin@.service.
+
+
+
+
@@ -259,8 +289,8 @@
Set the event log event type for this measurement. Pass help for a
list of currently defined identifiers. Defaults to an appropriate value for
- , , , and
- otherwise to phase.
+ , , ,
+ , and otherwise to phase.
diff --git a/src/login/logind-dbus.c b/src/login/logind-dbus.c
index 5ff5a1f656..b773dbfa42 100644
--- a/src/login/logind-dbus.c
+++ b/src/login/logind-dbus.c
@@ -4321,7 +4321,7 @@ int match_job_removed(sd_bus_message *message, void *userdata, sd_bus_error *err
/* If the user is stopping, we're tracking stop jobs here. So don't send reply. */
if (!user->stopping) {
char **user_job;
- FOREACH_ARGUMENT(user_job, &user->runtime_dir_job, &user->service_manager_job)
+ FOREACH_ARGUMENT(user_job, &user->runtime_dir_job, &user->service_manager_job, &user->measure_job)
if (streq_ptr(path, *user_job)) {
*user_job = mfree(*user_job);
diff --git a/src/login/logind-user.c b/src/login/logind-user.c
index 11ca04dc8d..c0342dfe16 100644
--- a/src/login/logind-user.c
+++ b/src/login/logind-user.c
@@ -10,6 +10,7 @@
#include "bus-locator.h"
#include "bus-util.h"
#include "clean-ipc.h"
+#include "efi-loader.h"
#include "env-file.h"
#include "errno-util.h"
#include "escape.h"
@@ -84,6 +85,10 @@ int user_new(Manager *m, UserRecord *ur, User **ret) {
if (r < 0)
return r;
+ r = unit_name_build("systemd-pcrlogin", lu, ".service", &u->measure_unit);
+ if (r < 0)
+ return r;
+
r = hashmap_put(m->users, UID_TO_PTR(ur->uid), u);
if (r < 0)
return r;
@@ -100,6 +105,10 @@ int user_new(Manager *m, UserRecord *ur, User **ret) {
if (r < 0)
return r;
+ r = hashmap_put(m->user_units, u->measure_unit, u);
+ if (r < 0)
+ return r;
+
*ret = TAKE_PTR(u);
return 0;
}
@@ -118,15 +127,21 @@ User *user_free(User *u) {
if (u->service_manager_unit) {
(void) hashmap_remove_value(u->manager->user_units, u->service_manager_unit, u);
- free(u->service_manager_job);
free(u->service_manager_unit);
}
+ free(u->service_manager_job);
if (u->runtime_dir_unit) {
(void) hashmap_remove_value(u->manager->user_units, u->runtime_dir_unit, u);
- free(u->runtime_dir_job);
free(u->runtime_dir_unit);
}
+ free(u->runtime_dir_job);
+
+ if (u->measure_unit) {
+ (void) hashmap_remove_value(u->manager->user_units, u->measure_unit, u);
+ free(u->measure_unit);
+ }
+ free(u->measure_job);
if (u->slice) {
(void) hashmap_remove_value(u->manager->user_units, u->slice, u);
@@ -177,6 +192,7 @@ static int user_save_internal(User *u) {
env_file_fputs_assignment(f, "RUNTIME=", u->runtime_path);
env_file_fputs_assignment(f, "RUNTIME_DIR_JOB=", u->runtime_dir_job);
env_file_fputs_assignment(f, "SERVICE_JOB=", u->service_manager_job);
+ env_file_fputs_assignment(f, "MEASURE_JOB=", u->measure_job);
if (u->display)
env_file_fputs_assignment(f, "DISPLAY=", u->display->id);
@@ -303,6 +319,7 @@ int user_load(User *u) {
r = parse_env_file(NULL, u->state_file,
"RUNTIME_DIR_JOB", &u->runtime_dir_job,
"SERVICE_JOB", &u->service_manager_job,
+ "MEASURE_JOB", &u->measure_job,
"STOPPING", &stopping,
"REALTIME", &realtime,
"MONOTONIC", &monotonic,
@@ -358,6 +375,39 @@ static int user_start_runtime_dir(User *u) {
return 0;
}
+static int user_start_measure(User *u) {
+ _cleanup_(sd_bus_error_free) sd_bus_error error = SD_BUS_ERROR_NULL;
+ int r;
+
+ assert(u);
+ assert(!u->stopping);
+ assert(u->manager);
+ assert(u->measure_unit);
+
+ /* Measures the user's record into the 'login' TPM2 NvPCR on the first login of this boot. This is
+ * strictly best-effort: a measurement failure must never block the login (simply because NvPCRs might
+ * not be available). Unlike the runtime-dir and service-manager units, this one is started but never
+ * stopped (see user_stop_service()): it is a Type=oneshot/RemainAfterExit unit living in
+ * system.slice that stays active until reboot, so a later re-login's StartUnit is a no-op and every
+ * user is measured exactly once per boot.
+ *
+ * Only bother if we're actually running in measured-OS mode. The unit itself also carries
+ * ConditionSecurity=measured-os, but checking here avoids queuing a no-op job on every first login
+ * on systems that don't measure the OS */
+ if (efi_measured_os(LOG_DEBUG) <= 0)
+ return 0;
+
+ u->measure_job = mfree(u->measure_job);
+
+ r = manager_start_unit(u->manager, u->measure_unit, &error, &u->measure_job);
+ if (r < 0)
+ return log_full_errno(sd_bus_error_has_name(&error, BUS_ERROR_UNIT_MASKED) ? LOG_DEBUG : LOG_WARNING,
+ r, "Failed to start user measurement service '%s', ignoring: %s",
+ u->measure_unit, bus_error_message(&error, r));
+
+ return 0;
+}
+
static bool user_wants_service_manager(const User *u) {
assert(u);
@@ -512,6 +562,14 @@ int user_start(User *u) {
/* Set slice parameters */
(void) user_update_slice(u);
+ /* Measure the user record into the 'login' NvPCR. We do this *before* starting the runtime dir
+ * (and, further below, the service manager): manager_start_unit() is synchronous, so by the
+ * time those units are enqueued the measurement's start job already exists, and their
+ * After=systemd-pcrlogin@%i.service ordering then guarantees the measurement completes before
+ * the user's session becomes available. Best-effort and never stopped again, so each user is
+ * measured exactly once per boot. */
+ (void) user_start_measure(u);
+
(void) user_start_runtime_dir(u);
}
diff --git a/src/login/logind-user.h b/src/login/logind-user.h
index b38af08744..2ae8faf375 100644
--- a/src/login/logind-user.h
+++ b/src/login/logind-user.h
@@ -43,6 +43,13 @@ typedef struct User {
char *service_manager_unit;
char *service_manager_job;
+ /* systemd-pcrlogin@UID.service — measures the user record into the 'login' NvPCR on first login.
+ * Unlike the units above, logind starts this but never stops it: it must run exactly once per boot,
+ * so it lives in system.slice, stays active until reboot, and is intentionally absent from
+ * user_stop_service(). */
+ char *measure_unit;
+ char *measure_job;
+
Session *display;
dual_timestamp timestamp; /* When this User object was 'started' the first time */
diff --git a/src/pcrextend/pcrextend.c b/src/pcrextend/pcrextend.c
index 278b3b730c..ac432aaac5 100644
--- a/src/pcrextend/pcrextend.c
+++ b/src/pcrextend/pcrextend.c
@@ -10,17 +10,19 @@
#include "efi-loader.h"
#include "escape.h"
#include "format-table.h"
+#include "help-util.h"
#include "json-util.h"
#include "main-func.h"
#include "options.h"
#include "parse-argument.h"
#include "pcrextend-util.h"
-#include "pretty-print.h"
#include "string-table.h"
#include "string-util.h"
#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,40 +43,31 @@ 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
static int help(void) {
- _cleanup_free_ char *link = NULL;
_cleanup_(table_unrefp) Table *options = NULL;
int r;
- r = terminal_urlify_man("systemd-pcrextend", "8", &link);
- if (r < 0)
- return log_oom();
-
r = option_parser_get_help_table(&options);
if (r < 0)
return r;
- printf("%1$s [OPTIONS...] WORD\n"
- "%1$s [OPTIONS...] --file-system=PATH\n"
- "%1$s [OPTIONS...] --machine-id\n"
- "%1$s [OPTIONS...] --product-id\n"
- "\n%2$sExtend a TPM2 PCR with boot phase, machine ID, or file system ID.%3$s\n",
- program_invocation_short_name,
- ansi_highlight(),
- ansi_normal());
-
- printf("\n%sOptions:%s\n",
- ansi_underline(),
- ansi_normal());
+ help_cmdline("[OPTIONS...] WORD");
+ help_cmdline("[OPTIONS...] --file-system=PATH");
+ help_cmdline("[OPTIONS...] --machine-id");
+ help_cmdline("[OPTIONS...] --product-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);
if (r < 0)
return r;
- printf("\nSee the %s for details.\n", link);
+ help_man_page_reference("systemd-pcrextend", "8");
return 0;
}
@@ -169,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;
@@ -185,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.");
@@ -199,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;
}
@@ -496,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.");
diff --git a/src/shared/pcrextend-util.c b/src/shared/pcrextend-util.c
index 79efdc4574..cf6eb7d1a5 100644
--- a/src/shared/pcrextend-util.c
+++ b/src/shared/pcrextend-util.c
@@ -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,
diff --git a/src/shared/pcrextend-util.h b/src/shared/pcrextend-util.h
index eadc2d5cff..c753c26568 100644
--- a/src/shared/pcrextend-util.h
+++ b/src/shared/pcrextend-util.h
@@ -3,11 +3,14 @@
#include
+#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);
diff --git a/src/shared/tpm2-util.c b/src/shared/tpm2-util.c
index 26ce8d7246..3f29730c7f 100644
--- a/src/shared/tpm2-util.c
+++ b/src/shared/tpm2-util.c
@@ -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);
diff --git a/src/shared/tpm2-util.h b/src/shared/tpm2-util.h
index bf2321a514..bc26a16947 100644
--- a/src/shared/tpm2-util.h
+++ b/src/shared/tpm2-util.h
@@ -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;
diff --git a/src/tpm2-setup/meson.build b/src/tpm2-setup/meson.build
index e87a13d8e6..55f0ce26d3 100644
--- a/src/tpm2-setup/meson.build
+++ b/src/tpm2-setup/meson.build
@@ -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
index 0000000000..babad7aaf8
--- /dev/null
+++ b/src/tpm2-setup/nvpcr/login.nvpcr.in
@@ -0,0 +1,6 @@
+{
+ "name" : "login",
+ "algorithm" : "sha256",
+ "nvIndex" : {{TPM2_NVPCR_BASE + 3}},
+ "priority" : 800
+}
diff --git a/test/units/TEST-70-TPM2.nvpcr.sh b/test/units/TEST-70-TPM2.nvpcr.sh
index d84bb1feb3..7787fb54ce 100755
--- a/test/units/TEST-70-TPM2.nvpcr.sh
+++ b/test/units/TEST-70-TPM2.nvpcr.sh
@@ -82,6 +82,41 @@ AAA_LINE="$(echo "$SETUP_LOG" | grep -n "Setting up NvPCR 'aaa'" | cut -d: -f1)"
ZZZ_LINE="$(echo "$SETUP_LOG" | grep -n "Setting up NvPCR 'zzz'" | cut -d: -f1)"
test "$ZZZ_LINE" -lt "$AAA_LINE"
+# Test the --login= mode and the 'login' NvPCR, used in production by systemd-pcrlogin@.service.
+if [[ -f /usr/lib/nvpcr/login.nvpcr ]]; then
+ login_nvpcr_value() {
+ systemd-analyze nvpcrs login --json=pretty | jq -r '.[] | select(.name=="login") | .value'
+ }
+
+ # Extract the most recently measured word for the 'login' NvPCR from the event log.
+ login_last_word() {
+ jq --seq --slurp -r '[.[] | select(.content.nvIndexName=="login") | .content.string] | last' :").
+ grep -F '"nvIndexName":"login","string":"login:root:' /run/log/systemd/tpm2-measure.log >/dev/null
+ LOGIN_WORD_BY_NAME="$(login_last_word)"
+
+ # Looking the same user up by numeric UID must yield the identical measured word
+ # (systemd-pcrextend uses USERDB_PARSE_NUMERIC, and systemd-pcrlogin@.service is instanced by UID).
+ "$SD_PCREXTEND" --login=0
+ LOGIN_WORD_BY_UID="$(login_last_word)"
+ test "$LOGIN_WORD_BY_NAME" = "$LOGIN_WORD_BY_UID"
+
+ # Direct tool invocations always re-extend (the once-per-boot guarantee lives in the unit's
+ # RemainAfterExit=yes, not in the tool), so the NvPCR value must have advanced.
+ LOGIN_DIGEST2="$(login_nvpcr_value)"
+ test "$LOGIN_DIGEST2" != "$LOGIN_DIGEST1"
+fi
+
systemd-analyze identify-tpm2
udevadm test-builtin 'tpm2_id identify' /dev/tpmrm0
diff --git a/units/meson.build b/units/meson.build
index c78835aa70..de7a796a1e 100644
--- a/units/meson.build
+++ b/units/meson.build
@@ -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
index 0000000000..dbdf488550
--- /dev/null
+++ b/units/systemd-pcrlogin@.service.in
@@ -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
diff --git a/units/user-runtime-dir@.service.in b/units/user-runtime-dir@.service.in
index 241e9267bb..d83b32570d 100644
--- a/units/user-runtime-dir@.service.in
+++ b/units/user-runtime-dir@.service.in
@@ -10,7 +10,10 @@
[Unit]
Description=User Runtime Directory /run/user/%i
Documentation=man:user@.service(5)
-After=systemd-logind.service dbus.service
+# Order after the user record measurement (started by logind before this unit), so that the user's record is
+# measured into the 'login' TPM2 NvPCR before the runtime dir — and thus the user's session — becomes
+# available. This is ordering only (no Wants=/Requires=): a measurement failure must never block login.
+After=systemd-logind.service dbus.service systemd-pcrlogin@%i.service
IgnoreOnIsolate=yes
[Service]
diff --git a/units/user@.service.in b/units/user@.service.in
index 381ab2a0db..a58af4a7d0 100644
--- a/units/user@.service.in
+++ b/units/user@.service.in
@@ -11,7 +11,9 @@
Description=User Manager for UID %i
Documentation=man:user@.service(5)
BindsTo=user-runtime-dir@%i.service
-After=systemd-logind.service user-runtime-dir@%i.service dbus.service systemd-oomd.service
+# Order after the user record measurement (see user-runtime-dir@.service), so the user's record is measured
+# into the 'login' TPM2 NvPCR before the user manager starts. Ordering only, so login is never blocked on it.
+After=systemd-logind.service user-runtime-dir@%i.service dbus.service systemd-oomd.service systemd-pcrlogin@%i.service
IgnoreOnIsolate=yes
[Service]