From 0bd861ff76a4badb8ebe302cd779a860de44aeb6 Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Fri, 22 May 2026 03:51:12 +0200 Subject: [PATCH 1/6] pcrextend: port to help-util.[ch] APIs --- src/pcrextend/pcrextend.c | 27 ++++++++------------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/src/pcrextend/pcrextend.c b/src/pcrextend/pcrextend.c index 278b3b730c..5109ebb897 100644 --- a/src/pcrextend/pcrextend.c +++ b/src/pcrextend/pcrextend.c @@ -10,12 +10,12 @@ #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" @@ -44,36 +44,25 @@ STATIC_DESTRUCTOR_REGISTER(arg_nvpcr_name, freep); #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_abstract("Extend a TPM2 PCR with boot phase, machine ID, or file system ID."); + 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; } From e32209c69c64c26dc35e27ceed463bb3b1169dcb Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Thu, 21 May 2026 14:47:59 +0200 Subject: [PATCH 2/6] pcrextend: add support for measuring a user record, to be executed on first login of the user 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 | 1 + man/systemd-pcrphase.service.xml | 36 +++++++++++++++++-- src/pcrextend/pcrextend.c | 44 +++++++++++++++++++---- src/shared/pcrextend-util.c | 54 +++++++++++++++++++++++++++++ src/shared/pcrextend-util.h | 3 ++ src/shared/tpm2-util.c | 1 + src/shared/tpm2-util.h | 1 + src/tpm2-setup/meson.build | 1 + src/tpm2-setup/nvpcr/login.nvpcr.in | 6 ++++ units/meson.build | 4 +++ units/systemd-pcrlogin@.service.in | 24 +++++++++++++ 11 files changed, 166 insertions(+), 9 deletions(-) create mode 100644 src/tpm2-setup/nvpcr/login.nvpcr.in create mode 100644 units/systemd-pcrlogin@.service.in 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.service systemd-pcrosseparator.service systemd-pcrproduct.service + systemd-pcrlogin@.service systemd-pcrfs-root.service systemd-pcrfs@.service systemd-pcrnvdone.service systemd-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.service systemd-pcrosseparator.service systemd-pcrproduct.service + systemd-pcrlogin@.service systemd-pcrfs-root.service systemd-pcrfs@.service systemd-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/pcrextend/pcrextend.c b/src/pcrextend/pcrextend.c index 5109ebb897..ac432aaac5 100644 --- a/src/pcrextend/pcrextend.c +++ b/src/pcrextend/pcrextend.c @@ -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."); 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/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 From 6f6ca5a224b745258456e6a869ba0504c6613db7 Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Thu, 21 May 2026 14:48:29 +0200 Subject: [PATCH 3/6] logind: automatically pull in systemd-pcrlogin@.service on first login --- src/login/logind-dbus.c | 2 +- src/login/logind-user.c | 62 +++++++++++++++++++++++++++++- src/login/logind-user.h | 7 ++++ units/user-runtime-dir@.service.in | 5 ++- units/user@.service.in | 4 +- 5 files changed, 75 insertions(+), 5 deletions(-) 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/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] From b7ad539c7dae89c89fadbcd726a571fdc0c3351d Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Thu, 21 May 2026 14:48:59 +0200 Subject: [PATCH 4/6] docs: document new measurement --- docs/TPM2_PCR_MEASUREMENTS.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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 From bbe2e14fe7346cd5ef4e08c2783c932c5d104c04 Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Thu, 21 May 2026 14:49:25 +0200 Subject: [PATCH 5/6] ci: add simple test case for new logic --- test/units/TEST-70-TPM2.nvpcr.sh | 35 ++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) 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 From 5c95871e4c02a20a9c78af792b49beb5de6413fd Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Thu, 21 May 2026 14:51:58 +0200 Subject: [PATCH 6/6] update TODO --- TODO.md | 4 ---- 1 file changed, 4 deletions(-) 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