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.
This commit is contained in:
Lennart Poettering
2026-05-22 12:15:12 +02:00
parent 0bd861ff76
commit e32209c69c
11 changed files with 166 additions and 9 deletions
+1
View 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',
+33 -3
View File
@@ -23,11 +23,12 @@
<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>
@@ -64,6 +66,20 @@
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>
@@ -244,6 +260,20 @@
<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>
@@ -259,8 +289,8 @@
<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>
+38 -6
View 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.");
+54
View 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,
+3
View 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);
+1
View 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);
+1
View 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;
+1
View 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(
+6
View File
@@ -0,0 +1,6 @@
{
"name" : "login",
"algorithm" : "sha256",
"nvIndex" : {{TPM2_NVPCR_BASE + 3}},
"priority" : 800
}
+4
View 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'],
+24
View File
@@ -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