verity: measure all root hashes as we activate to a new NvPCR (#40648)
This commit is contained in:
@@ -720,10 +720,6 @@ Features:
|
||||
deleting entries for rotation, place an event that declares how many items
|
||||
have been dropped, and what the hash before and after that.
|
||||
|
||||
* measure information about all DDIs as we activate them to an NvPCR. We
|
||||
probably should measure the dm-verity root hash from the kernel side, but
|
||||
DDI meta info from userspace.
|
||||
|
||||
* use name_to_handle_at() with AT_HANDLE_FID instead of .st_ino (inode
|
||||
number) for identifying inodes, for example in copy.c when finding hard
|
||||
links, or loop-util.c for tracking backing files, and other places.
|
||||
@@ -1299,9 +1295,9 @@ Features:
|
||||
- If run on every boot, should it use the sysupdate config from the host on
|
||||
subsequent boots?
|
||||
|
||||
* To mimic the new tpm2-measure-pcr= crypttab option add the same to veritytab
|
||||
(measuring the root hash) and integritytab (measuring the HMAC key if one is
|
||||
used)
|
||||
* To mimic the new tpm2-measure-pcr= crypttab option and tpm2-measure-nvpcr=
|
||||
veritytab option, add the same to integritytab (measuring the HMAC key if one
|
||||
is used)
|
||||
|
||||
* We should start measuring all services, containers, and system extensions we
|
||||
activate. probably into PCR 13. i.e. add --tpm2-measure-pcr= or so to
|
||||
@@ -1720,18 +1716,6 @@ Features:
|
||||
keys of /etc/crypttab. That way people can store/provide the roothash
|
||||
externally and provide to us on demand only.
|
||||
|
||||
* we probably should extend the root verity hash of the root fs into some PCR
|
||||
on boot. (i.e. maybe add a veritytab option tpm2-measure=12 or so to measure
|
||||
it into PCR 12); Similar: we probably should extend the LUKS volume key of
|
||||
the root fs into some PCR on boot. (i.e. maybe add a crypttab option
|
||||
tpm2-measure=15 or so to measure it into PCR 15); once both are in place
|
||||
update gpt-auto-discovery to generate these by default for the partitions it
|
||||
discovers. Static vendor stuff should probably end up in PCR 12 (i.e. the
|
||||
verity hash), with local keys in PCR 15 (i.e. the encryption volume
|
||||
key). That way, we nicely distinguish resources supplied by the OS vendor
|
||||
(i.e. sysext, root verity) from those inherently local (i.e. encryption key),
|
||||
which is useful if they shall be signed separately.
|
||||
|
||||
* rework recursive read-only remount to use new mount API
|
||||
|
||||
* when mounting disk images: if IMAGE_ID/IMAGE_VERSION is set in os-release
|
||||
|
||||
+6
-6
@@ -945,12 +945,12 @@
|
||||
<term><option>tpm2-measure-keyslot-nvpcr=</option></term>
|
||||
|
||||
<listitem><para>Controls whether to measure information about the used LUKS unlock keyslot to a TPM2
|
||||
non-volatile index (nvindex in PCR mode). If set to to an empty string (which is the default) no TPM2
|
||||
nvindex extension is done, otherwise keyslot information is measured to an nvindex of the specified
|
||||
name, which is allocated if needed. It is recommended to set this to <literal>cryptsetup</literal> to
|
||||
enable this logic. The slot index and the used unlock mechanism (i.e. <literal>tpm2</literal>,
|
||||
<literal>fido2</literal>, <literal>pkcs11</literal>) is measured along with the activated volume name
|
||||
and its UUID.</para>
|
||||
non-volatile index (nvindex in PCR mode). Takes a boolean argument, or an NvPCR name. If set to false
|
||||
or an empty string (which is the default) no TPM2 nvindex extension is done, otherwise keyslot
|
||||
information is measured to an nvindex of the specified name, which is allocated if needed. If set to
|
||||
true the recommended default of <literal>cryptsetup</literal> is selected as NvPCR. The slot index
|
||||
and the used unlock mechanism (i.e. <literal>tpm2</literal>, <literal>fido2</literal>,
|
||||
<literal>pkcs11</literal>) are measured along with the activated volume name and its UUID.</para>
|
||||
|
||||
<xi:include href="version-info.xml" xpointer="v259"/></listitem>
|
||||
</varlistentry>
|
||||
|
||||
@@ -272,9 +272,13 @@
|
||||
<para>If the system was booted via
|
||||
<citerefentry><refentrytitle>systemd-stub</refentrytitle><manvolnum>7</manvolnum></citerefentry> and the
|
||||
stub reported to userspace that the kernel image was measured to a TPM2 PCR, then any discovered root and
|
||||
<filename>/var/</filename> volume identifiers (and volume encryption key in case it is encrypted) will be
|
||||
automatically measured into PCR 15 on activation, via
|
||||
<citerefentry><refentrytitle>systemd-pcrfs@.service</refentrytitle><manvolnum>8</manvolnum></citerefentry>.</para>
|
||||
<filename>/var/</filename> volume identifiers (and volume encryption keys, in case they are encrypted)
|
||||
will be automatically measured into PCR 15 on activation, via
|
||||
<citerefentry><refentrytitle>systemd-pcrfs@.service</refentrytitle><manvolnum>8</manvolnum></citerefentry>. Moreover,
|
||||
information about the LUKS key slot used to unlock the volume is measured into NvPCR
|
||||
<literal>cryptsetup</literal>. Finally, if the root or <filename>/usr/</filename> partition is protected
|
||||
via Verity its root hash and the serial/issuer of the key used for the provided root hash signature (if
|
||||
any) are measured into the NvPCR <literal>verity</literal>.</para>
|
||||
|
||||
<para>Mount constraint metadata contained in the file systems is validated by pulling in
|
||||
<citerefentry><refentrytitle>systemd-validatefs@.service</refentrytitle><manvolnum>8</manvolnum></citerefentry>
|
||||
|
||||
@@ -298,6 +298,18 @@ This is based on crypttab(5).
|
||||
</listitem>
|
||||
</varlistentry>
|
||||
|
||||
<varlistentry>
|
||||
<term><option>tpm2-measure-nvpcr=</option></term>
|
||||
|
||||
<listitem><para>Takes a boolean argument, or an NvPCR name as argument. May be used to enable
|
||||
automatic measurement of the volume name and Verity root hash, as well as the serials and issuers of
|
||||
the certificates used to generate the provided signatures (if any) will be measured. Passing false
|
||||
disables the mechanism. Passing true enables measurement into the <literal>verity</literal> NvPCR. If
|
||||
any other string is specified this selects the NvPCR to measure into by name.</para>
|
||||
|
||||
<xi:include href="version-info.xml" xpointer="v260"/></listitem>
|
||||
</varlistentry>
|
||||
|
||||
</variablelist>
|
||||
|
||||
<para>At early boot and when the system manager configuration is
|
||||
|
||||
@@ -557,12 +557,14 @@ static int parse_one_option(const char *option) {
|
||||
|
||||
} else if ((val = startswith(option, "tpm2-measure-keyslot-nvpcr="))) {
|
||||
|
||||
if (isempty(val)) {
|
||||
r = isempty(val) ? false : parse_boolean(val);
|
||||
if (r == 0) {
|
||||
arg_tpm2_measure_keyslot_nvpcr = mfree(arg_tpm2_measure_keyslot_nvpcr);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!tpm2_nvpcr_name_is_valid(val)) {
|
||||
if (r > 0)
|
||||
val = "cryptsetup";
|
||||
else if (!tpm2_nvpcr_name_is_valid(val)) {
|
||||
log_warning("Invalid NvPCR name, ignoring: %s", option);
|
||||
return 0;
|
||||
}
|
||||
@@ -1095,8 +1097,9 @@ static int measure_keyslot(
|
||||
const char *mechanism,
|
||||
int keyslot) {
|
||||
|
||||
#if HAVE_TPM2
|
||||
int r;
|
||||
|
||||
#endif
|
||||
assert(cd);
|
||||
assert(name);
|
||||
|
||||
@@ -1105,6 +1108,7 @@ static int measure_keyslot(
|
||||
return 0;
|
||||
}
|
||||
|
||||
#if HAVE_TPM2
|
||||
r = efi_measured_uki(LOG_WARNING);
|
||||
if (r < 0)
|
||||
return r;
|
||||
@@ -1113,7 +1117,6 @@ static int measure_keyslot(
|
||||
return 0;
|
||||
}
|
||||
|
||||
#if HAVE_TPM2
|
||||
_cleanup_(tpm2_context_unrefp) Tpm2Context *c = NULL;
|
||||
r = tpm2_context_new_or_warn(arg_tpm2_device, &c);
|
||||
if (r < 0)
|
||||
|
||||
@@ -127,7 +127,7 @@ static int add_cryptsetup(
|
||||
* assignment, under the assumption that people who are fine to use sd-stub with its PCR
|
||||
* assignments are also OK with our PCR 15 use here. */
|
||||
if (r > 0)
|
||||
if (!strextend_with_separator(&options, ",", "tpm2-measure-pcr=yes,tpm2-measure-keyslot-nvpcr=cryptsetup"))
|
||||
if (!strextend_with_separator(&options, ",", "tpm2-measure-pcr=yes,tpm2-measure-keyslot-nvpcr=yes"))
|
||||
return log_oom();
|
||||
if (r == 0)
|
||||
log_debug("Will not measure volume key of volume '%s', not booted via systemd-stub with measurements enabled.", id);
|
||||
@@ -190,7 +190,8 @@ static int add_veritysetup(
|
||||
const char *id,
|
||||
const char *data_what,
|
||||
const char *hash_what,
|
||||
const char *mount_opts) {
|
||||
const char *mount_opts,
|
||||
MountPointFlags flags) {
|
||||
|
||||
#if HAVE_LIBCRYPTSETUP
|
||||
int r;
|
||||
@@ -233,13 +234,26 @@ static int add_veritysetup(
|
||||
"After=%1$s %2$s\n",
|
||||
dd, dh);
|
||||
|
||||
_cleanup_free_ char *options =
|
||||
strdup("root-hash-signature=auto"); /* auto means: derive signature from udev property ID_DISSECT_PART_ROOTHASH_SIG */
|
||||
if (!options)
|
||||
return log_oom();
|
||||
|
||||
if (FLAGS_SET(flags, MOUNT_MEASURE)) {
|
||||
r = efi_measured_uki(LOG_WARNING);
|
||||
if (r > 0 && !strextend_with_separator(&options, ",", "tpm2-measure-nvpcr=yes"))
|
||||
return log_oom();
|
||||
if (r == 0)
|
||||
log_debug("Will not measure root hash/signature of volume '%s', not booted via systemd-stub with measurements enabled.", id);
|
||||
}
|
||||
|
||||
r = generator_write_veritysetup_service_section(
|
||||
f,
|
||||
id,
|
||||
data_what,
|
||||
hash_what,
|
||||
/* roothash= */ NULL, /* NULL means: derive root hash from udev property ID_DISSECT_PART_ROOTHASH */
|
||||
"root-hash-signature=auto"); /* auto means: derive signature from udev property ID_DISSECT_PART_ROOTHASH_SIG */
|
||||
options);
|
||||
if (r < 0)
|
||||
return r;
|
||||
|
||||
@@ -871,7 +885,8 @@ static int add_root_mount(void) {
|
||||
"root",
|
||||
"/dev/disk/by-designator/root-verity-data",
|
||||
"/dev/disk/by-designator/root-verity",
|
||||
arg_root_options);
|
||||
arg_root_options,
|
||||
MOUNT_MEASURE);
|
||||
if (r < 0)
|
||||
return r;
|
||||
}
|
||||
@@ -952,7 +967,8 @@ static int add_usr_mount(void) {
|
||||
"usr",
|
||||
"/dev/disk/by-designator/usr-verity-data",
|
||||
"/dev/disk/by-designator/usr-verity",
|
||||
arg_usr_options);
|
||||
arg_usr_options,
|
||||
MOUNT_MEASURE);
|
||||
if (r < 0)
|
||||
return r;
|
||||
}
|
||||
|
||||
@@ -369,6 +369,7 @@ typedef struct MethodExtendParameters {
|
||||
const char *nvpcr;
|
||||
const char *text;
|
||||
struct iovec data;
|
||||
Tpm2UserspaceEventType event_type;
|
||||
} MethodExtendParameters;
|
||||
|
||||
static void method_extend_parameters_done(MethodExtendParameters *p) {
|
||||
@@ -377,17 +378,21 @@ static void method_extend_parameters_done(MethodExtendParameters *p) {
|
||||
iovec_done(&p->data);
|
||||
}
|
||||
|
||||
static JSON_DISPATCH_ENUM_DEFINE(json_dispatch_tpm2_userspace_event_type, Tpm2UserspaceEventType, tpm2_userspace_event_type_from_string);
|
||||
|
||||
static int vl_method_extend(sd_varlink *link, sd_json_variant *parameters, sd_varlink_method_flags_t flags, void *userdata) {
|
||||
|
||||
static const sd_json_dispatch_field dispatch_table[] = {
|
||||
{ "pcr", _SD_JSON_VARIANT_TYPE_INVALID, sd_json_dispatch_uint, offsetof(MethodExtendParameters, pcr), 0 },
|
||||
{ "nvpcr", SD_JSON_VARIANT_STRING, sd_json_dispatch_const_string, offsetof(MethodExtendParameters, nvpcr), 0 },
|
||||
{ "text", SD_JSON_VARIANT_STRING, sd_json_dispatch_const_string, offsetof(MethodExtendParameters, text), 0 },
|
||||
{ "data", SD_JSON_VARIANT_STRING, json_dispatch_unbase64_iovec, offsetof(MethodExtendParameters, data), 0 },
|
||||
{ "pcr", _SD_JSON_VARIANT_TYPE_INVALID, sd_json_dispatch_uint, offsetof(MethodExtendParameters, pcr), 0 },
|
||||
{ "nvpcr", SD_JSON_VARIANT_STRING, sd_json_dispatch_const_string, offsetof(MethodExtendParameters, nvpcr), 0 },
|
||||
{ "text", SD_JSON_VARIANT_STRING, sd_json_dispatch_const_string, offsetof(MethodExtendParameters, text), 0 },
|
||||
{ "data", SD_JSON_VARIANT_STRING, json_dispatch_unbase64_iovec, offsetof(MethodExtendParameters, data), 0 },
|
||||
{ "eventType", SD_JSON_VARIANT_STRING, json_dispatch_tpm2_userspace_event_type, offsetof(MethodExtendParameters, event_type), 0 },
|
||||
{}
|
||||
};
|
||||
_cleanup_(method_extend_parameters_done) MethodExtendParameters p = {
|
||||
.pcr = UINT_MAX,
|
||||
.event_type = _TPM2_USERSPACE_EVENT_TYPE_INVALID,
|
||||
};
|
||||
int r;
|
||||
|
||||
@@ -424,11 +429,11 @@ static int vl_method_extend(sd_varlink *link, sd_json_variant *parameters, sd_va
|
||||
return sd_varlink_error_invalid_parameter_name(link, "text");
|
||||
|
||||
if (p.nvpcr) {
|
||||
r = extend_nvpcr_now(p.nvpcr, extend_iovec->iov_base, extend_iovec->iov_len, _TPM2_USERSPACE_EVENT_TYPE_INVALID);
|
||||
r = extend_nvpcr_now(p.nvpcr, extend_iovec->iov_base, extend_iovec->iov_len, p.event_type);
|
||||
if (r == -ENOENT)
|
||||
return sd_varlink_error(link, "io.systemd.PCRExtend.NoSuchNvPCR", NULL);
|
||||
} else
|
||||
r = extend_pcr_now(p.pcr, extend_iovec->iov_base, extend_iovec->iov_len, _TPM2_USERSPACE_EVENT_TYPE_INVALID);
|
||||
r = extend_pcr_now(p.pcr, extend_iovec->iov_base, extend_iovec->iov_len, p.event_type);
|
||||
if (r < 0)
|
||||
return r;
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
#include "openssl-util.h"
|
||||
#include "os-util.h"
|
||||
#include "path-util.h"
|
||||
#include "pcrextend-util.h"
|
||||
#include "pidref.h"
|
||||
#include "proc-cmdline.h"
|
||||
#include "process-util.h"
|
||||
@@ -3219,13 +3220,13 @@ static int do_crypt_activate_verity(
|
||||
DissectImageFlags flags,
|
||||
PartitionPolicyFlags policy_flags) {
|
||||
|
||||
bool check_signature;
|
||||
int r, k;
|
||||
int r;
|
||||
|
||||
assert(cd);
|
||||
assert(name);
|
||||
assert(verity);
|
||||
|
||||
bool check_signature;
|
||||
if (iovec_is_set(&verity->root_hash_sig) && FLAGS_SET(policy_flags, PARTITION_POLICY_SIGNED)) {
|
||||
r = secure_getenv_bool("SYSTEMD_DISSECT_VERITY_SIGNATURE");
|
||||
if (r < 0 && r != -ENXIO)
|
||||
@@ -3235,7 +3236,10 @@ static int do_crypt_activate_verity(
|
||||
} else
|
||||
check_signature = false;
|
||||
|
||||
bool measure_signature;
|
||||
if (check_signature) {
|
||||
int k;
|
||||
|
||||
/* First, if we have support for signed keys in the kernel, then try that first. */
|
||||
r = sym_crypt_activate_by_signed_key(
|
||||
cd,
|
||||
@@ -3247,7 +3251,8 @@ static int do_crypt_activate_verity(
|
||||
CRYPT_ACTIVATE_READONLY);
|
||||
if (r >= 0) {
|
||||
log_debug("Verity activation via kernel signature logic worked.");
|
||||
return 0;
|
||||
measure_signature = true;
|
||||
goto done;
|
||||
}
|
||||
|
||||
log_debug_errno(r, "Validation of dm-verity signature failed via the kernel, trying userspace validation instead: %m");
|
||||
@@ -3280,9 +3285,13 @@ static int do_crypt_activate_verity(
|
||||
|
||||
/* Otherwise let's see what signature-less activation results in. */
|
||||
|
||||
measure_signature = true;
|
||||
|
||||
} else if (!FLAGS_SET(policy_flags, PARTITION_POLICY_VERITY))
|
||||
return log_debug_errno(SYNTHETIC_ERRNO(ERFKILL),
|
||||
"No-signature activation of Verity volume not allowed by policy, refusing.");
|
||||
else
|
||||
measure_signature = false;
|
||||
|
||||
r = sym_crypt_activate_by_volume_key(
|
||||
cd,
|
||||
@@ -3294,6 +3303,12 @@ static int do_crypt_activate_verity(
|
||||
return log_debug_errno(r, "Activation of Verity via root hash failed: %m");
|
||||
|
||||
log_debug("Activation of Verity via root hash succeeded.");
|
||||
|
||||
done:
|
||||
(void) pcrextend_verity_now(
|
||||
name,
|
||||
&verity->root_hash,
|
||||
measure_signature ? &verity->root_hash_sig : NULL);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -152,6 +152,7 @@ shared_sources = files(
|
||||
'pcre2-util.c',
|
||||
'pcrextend-util.c',
|
||||
'pe-binary.c',
|
||||
'pkcs7-util.c',
|
||||
'pkcs11-util.c',
|
||||
'plymouth-util.c',
|
||||
'polkit-agent.c',
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "sd-device.h"
|
||||
#include "sd-id128.h"
|
||||
#include "sd-varlink.h"
|
||||
|
||||
#include "alloc-util.h"
|
||||
#include "blkid-util.h"
|
||||
@@ -10,10 +11,13 @@
|
||||
#include "errno-util.h"
|
||||
#include "escape.h"
|
||||
#include "fd-util.h"
|
||||
#include "hexdecoct.h"
|
||||
#include "id128-util.h"
|
||||
#include "iovec-util.h"
|
||||
#include "log.h"
|
||||
#include "mountpoint-util.h"
|
||||
#include "pcrextend-util.h"
|
||||
#include "pkcs7-util.h"
|
||||
#include "string-util.h"
|
||||
#include "strv.h"
|
||||
|
||||
@@ -180,3 +184,110 @@ int pcrextend_product_id_word(char **ret) {
|
||||
*ret = TAKE_PTR(word);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int pcrextend_verity_word(
|
||||
const char *name,
|
||||
const struct iovec *root_hash,
|
||||
const struct iovec *root_hash_sig,
|
||||
char **ret) {
|
||||
|
||||
int r;
|
||||
|
||||
assert(name);
|
||||
assert(iovec_is_set(root_hash));
|
||||
|
||||
_cleanup_free_ char *name_escaped = xescape(name, ":"); /* Avoid ambiguity around ":" */
|
||||
if (!name_escaped)
|
||||
return log_oom();
|
||||
|
||||
_cleanup_free_ char *h = hexmem(root_hash->iov_base, root_hash->iov_len);
|
||||
if (!h)
|
||||
return log_oom();
|
||||
|
||||
_cleanup_free_ char *sigs = NULL;
|
||||
if (iovec_is_set(root_hash_sig)) {
|
||||
size_t n_signers = 0;
|
||||
Signer *signers = NULL;
|
||||
|
||||
/* Let's extract the X.509 issuer + serial number from the PKCS#7 signature and include that
|
||||
* in the measurement record. This is useful since it allows us to have different signing
|
||||
* keys for confext + sysext + other types of DDIs, and by means of this information we can
|
||||
* discern which kind it was. Ideally, we'd measure the fingerprint of the X.509 certificate,
|
||||
* but typically that's not available in a PKCS#7 signature. */
|
||||
|
||||
CLEANUP_ARRAY(signers, n_signers, signer_free_many);
|
||||
|
||||
r = pkcs7_extract_signers(root_hash_sig, &signers, &n_signers);
|
||||
if (r < 0)
|
||||
return r;
|
||||
|
||||
FOREACH_ARRAY(i, signers, n_signers) {
|
||||
_cleanup_free_ char *serial = hexmem(i->serial.iov_base, i->serial.iov_len);
|
||||
if (!serial)
|
||||
return log_oom();
|
||||
|
||||
_cleanup_free_ char *issuer = NULL;
|
||||
if (base64mem(i->issuer.iov_base, i->issuer.iov_len, &issuer) < 0)
|
||||
return log_oom();
|
||||
|
||||
if (strextendf_with_separator(&sigs, ",", "%s/%s", serial, issuer) < 0)
|
||||
return log_oom();
|
||||
}
|
||||
}
|
||||
|
||||
_cleanup_free_ char *word = strjoin("verity:", name_escaped, ":", h, ":", strempty(sigs));
|
||||
if (!word)
|
||||
return log_oom();
|
||||
|
||||
*ret = TAKE_PTR(word);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int pcrextend_verity_now(
|
||||
const char *name,
|
||||
const struct iovec *root_hash,
|
||||
const struct iovec *root_hash_sig) {
|
||||
|
||||
#if HAVE_TPM2
|
||||
int r;
|
||||
|
||||
_cleanup_free_ char *word = NULL;
|
||||
r = pcrextend_verity_word(
|
||||
name,
|
||||
root_hash,
|
||||
root_hash_sig,
|
||||
&word);
|
||||
if (r < 0)
|
||||
return r;
|
||||
|
||||
_cleanup_free_ sd_varlink *vl = NULL;
|
||||
r = sd_varlink_connect_address(&vl, "/run/systemd/io.systemd.PCRExtend");
|
||||
if (r < 0)
|
||||
return r;
|
||||
|
||||
_cleanup_(sd_json_variant_unrefp) sd_json_variant *reply = NULL;
|
||||
const char *error_id = NULL;
|
||||
r = sd_varlink_callbo(
|
||||
vl,
|
||||
"io.systemd.PCRExtend.Extend",
|
||||
/* ret_reply= */ NULL,
|
||||
&error_id,
|
||||
SD_JSON_BUILD_PAIR_STRING("nvpcr", "verity"),
|
||||
SD_JSON_BUILD_PAIR_STRING("text", word),
|
||||
SD_JSON_BUILD_PAIR_STRING("eventType", "dm_verity"));
|
||||
if (r < 0)
|
||||
return log_debug_errno(r, "Failed to issue io.systemd.PCRExtend.Extend() varlink call: %m");
|
||||
if (error_id) {
|
||||
r = sd_varlink_error_to_errno(error_id, reply);
|
||||
if (r != -EBADR)
|
||||
return log_debug_errno(r, "Failed to issue io.systemd.PCRExtend.Extend() varlink call: %m");
|
||||
|
||||
return log_debug_errno(r, "Failed to issue io.systemd.PCRExtend.Extend() varlink call: %s", error_id);
|
||||
}
|
||||
|
||||
log_debug("Measurement of '%s' into 'images' NvPCR completed.", word);
|
||||
return 1;
|
||||
#else
|
||||
return log_error_errno(SYNTHETIC_ERRNO(EOPNOTSUPP), "TPM2 support disabled, not measuring Verity root hashes and signatures.");
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -4,3 +4,6 @@
|
||||
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_verity_now(const char *name, const struct iovec *root_hash,const struct iovec *root_hash_sig);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/* SPDX-License-Identifier: LGPL-2.1-or-later */
|
||||
|
||||
#include "alloc-util.h"
|
||||
#include "openssl-util.h"
|
||||
#include "pkcs7-util.h"
|
||||
#include "log.h"
|
||||
|
||||
#define SIGNERS_MAX 32
|
||||
|
||||
static void signer_done(Signer *signer) {
|
||||
assert(signer);
|
||||
|
||||
iovec_done(&signer->issuer);
|
||||
iovec_done(&signer->serial);
|
||||
}
|
||||
|
||||
void signer_free_many(Signer *signers, size_t n) {
|
||||
assert(signers || n == 0);
|
||||
|
||||
FOREACH_ARRAY(i, signers, n)
|
||||
signer_done(i);
|
||||
|
||||
free(signers);
|
||||
}
|
||||
|
||||
int pkcs7_extract_signers(
|
||||
const struct iovec *sig,
|
||||
Signer **ret_signers,
|
||||
size_t *ret_n_signers) {
|
||||
|
||||
assert(ret_signers);
|
||||
assert(ret_n_signers);
|
||||
|
||||
if (!iovec_is_set(sig))
|
||||
return -EBADMSG;
|
||||
|
||||
#if HAVE_OPENSSL
|
||||
const unsigned char *d = sig->iov_base;
|
||||
_cleanup_(PKCS7_freep) PKCS7 *p7 = NULL;
|
||||
p7 = d2i_PKCS7(/* a= */ NULL, &d, (long) sig->iov_len);
|
||||
if (!p7)
|
||||
return log_debug_errno(SYNTHETIC_ERRNO(EBADMSG), "Failed to parse PKCS7 DER signature data.");
|
||||
|
||||
STACK_OF(PKCS7_SIGNER_INFO) *sinfos = PKCS7_get_signer_info(p7);
|
||||
if (!sinfos)
|
||||
return log_debug_errno(SYNTHETIC_ERRNO(ENODATA), "No signature information in PKCS7 signature?");
|
||||
int n = sk_PKCS7_SIGNER_INFO_num(sinfos);
|
||||
if (n == 0)
|
||||
return log_debug_errno(SYNTHETIC_ERRNO(ENODATA), "No signatures in PKCS7 signature, refusing.");
|
||||
if (n > SIGNERS_MAX) /* safety net, in case people send us weirdly complex signatures */
|
||||
return log_debug_errno(SYNTHETIC_ERRNO(EBADMSG), "Too many signatures, refusing.");
|
||||
assert(n > 0);
|
||||
|
||||
size_t n_signers = 0;
|
||||
Signer *signers = new(Signer, n);
|
||||
if (!signers)
|
||||
return log_oom_debug();
|
||||
|
||||
CLEANUP_ARRAY(signers, n_signers, signer_free_many);
|
||||
|
||||
for (int i = 0; i < n; i++) {
|
||||
PKCS7_SIGNER_INFO *si = sk_PKCS7_SIGNER_INFO_value(PKCS7_get_signer_info(p7), i);
|
||||
if (!si)
|
||||
return log_debug_errno(SYNTHETIC_ERRNO(ENOTRECOVERABLE), "Failed to get signer information.");
|
||||
|
||||
_cleanup_(signer_done) Signer signer = {};
|
||||
|
||||
_cleanup_free_ unsigned char *p = NULL;
|
||||
int len = i2d_X509_NAME(si->issuer_and_serial->issuer, &p);
|
||||
signer.issuer = IOVEC_MAKE(TAKE_PTR(p), len);
|
||||
|
||||
len = i2d_ASN1_INTEGER(si->issuer_and_serial->serial, &p);
|
||||
signer.serial = IOVEC_MAKE(TAKE_PTR(p), len);
|
||||
|
||||
signers[n_signers++] = TAKE_STRUCT(signer);
|
||||
}
|
||||
|
||||
*ret_signers = TAKE_PTR(signers);
|
||||
*ret_n_signers = n_signers;
|
||||
return n;
|
||||
#else
|
||||
return -EOPNOTSUPP;
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/* SPDX-License-Identifier: LGPL-2.1-or-later */
|
||||
#pragma once
|
||||
|
||||
#include <sys/uio.h>
|
||||
|
||||
typedef struct Signer {
|
||||
struct iovec issuer;
|
||||
struct iovec serial;
|
||||
} Signer;
|
||||
|
||||
void signer_free_many(Signer *signers, size_t n);
|
||||
|
||||
int pkcs7_extract_signers(
|
||||
const struct iovec *sig,
|
||||
Signer **ret_signers,
|
||||
size_t *ret_n_signers);
|
||||
@@ -5441,7 +5441,7 @@ int tpm2_seal(Tpm2Context *c,
|
||||
seal_key_handle);
|
||||
|
||||
primary_alg = primary_public->publicArea.type;
|
||||
|
||||
|
||||
/* Propagate fixedTPM/fixedParent flags from sealing key to hmac key */
|
||||
hmac_template.objectAttributes = (hmac_template.objectAttributes & ~(TPMA_OBJECT_FIXEDTPM|TPMA_OBJECT_FIXEDPARENT)) |
|
||||
(primary_public->publicArea.objectAttributes & (TPMA_OBJECT_FIXEDTPM|TPMA_OBJECT_FIXEDPARENT));
|
||||
@@ -6478,6 +6478,7 @@ static const char* tpm2_userspace_event_type_table[_TPM2_USERSPACE_EVENT_TYPE_MA
|
||||
[TPM2_EVENT_KEYSLOT] = "keyslot",
|
||||
[TPM2_EVENT_NVPCR_INIT] = "nvpcr-init",
|
||||
[TPM2_EVENT_NVPCR_SEPARATOR] = "nvpcr-separator",
|
||||
[TPM2_EVENT_DM_VERITY] = "dm-verity",
|
||||
};
|
||||
|
||||
DEFINE_STRING_TABLE_LOOKUP(tpm2_userspace_event_type, Tpm2UserspaceEventType);
|
||||
|
||||
@@ -145,6 +145,7 @@ typedef enum Tpm2UserspaceEventType {
|
||||
TPM2_EVENT_KEYSLOT,
|
||||
TPM2_EVENT_NVPCR_INIT,
|
||||
TPM2_EVENT_NVPCR_SEPARATOR,
|
||||
TPM2_EVENT_DM_VERITY,
|
||||
_TPM2_USERSPACE_EVENT_TYPE_MAX,
|
||||
_TPM2_USERSPACE_EVENT_TYPE_INVALID = -EINVAL,
|
||||
} Tpm2UserspaceEventType;
|
||||
|
||||
@@ -2,6 +2,18 @@
|
||||
|
||||
#include "varlink-io.systemd.PCRExtend.h"
|
||||
|
||||
static SD_VARLINK_DEFINE_ENUM_TYPE(
|
||||
EventType,
|
||||
SD_VARLINK_DEFINE_ENUM_VALUE(phase),
|
||||
SD_VARLINK_DEFINE_ENUM_VALUE(filesystem),
|
||||
SD_VARLINK_DEFINE_ENUM_VALUE(volume_key),
|
||||
SD_VARLINK_DEFINE_ENUM_VALUE(machine_id),
|
||||
SD_VARLINK_DEFINE_ENUM_VALUE(product_id),
|
||||
SD_VARLINK_DEFINE_ENUM_VALUE(keyslot),
|
||||
SD_VARLINK_DEFINE_ENUM_VALUE(nvpcr_init),
|
||||
SD_VARLINK_DEFINE_ENUM_VALUE(nvpcr_separator),
|
||||
SD_VARLINK_DEFINE_ENUM_VALUE(dm_verity));
|
||||
|
||||
static SD_VARLINK_DEFINE_METHOD(
|
||||
Extend,
|
||||
SD_VARLINK_FIELD_COMMENT("PCR number to extend, in range of 0…23. Either this or 'nvpcr' must be specified, not both, not neither."),
|
||||
@@ -11,7 +23,9 @@ static SD_VARLINK_DEFINE_METHOD(
|
||||
SD_VARLINK_FIELD_COMMENT("Text string to measure. (Specify either this, or the 'data' field below, not both)"),
|
||||
SD_VARLINK_DEFINE_INPUT(text, SD_VARLINK_STRING, SD_VARLINK_NULLABLE),
|
||||
SD_VARLINK_FIELD_COMMENT("Binary data to measure, encoded in Base64. (Specify either this, or the 'text' field above, not both)"),
|
||||
SD_VARLINK_DEFINE_INPUT(data, SD_VARLINK_STRING, SD_VARLINK_NULLABLE));
|
||||
SD_VARLINK_DEFINE_INPUT(data, SD_VARLINK_STRING, SD_VARLINK_NULLABLE),
|
||||
SD_VARLINK_FIELD_COMMENT("Event type to include in the (userspace) event log). This is optional, and mostly for debugging."),
|
||||
SD_VARLINK_DEFINE_INPUT_BY_TYPE(eventType, EventType, SD_VARLINK_NULLABLE));
|
||||
|
||||
static SD_VARLINK_DEFINE_ERROR(NoSuchNvPCR);
|
||||
|
||||
@@ -21,4 +35,6 @@ SD_VARLINK_DEFINE_INTERFACE(
|
||||
SD_VARLINK_INTERFACE_COMMENT("TPM PCR Extension APIs"),
|
||||
SD_VARLINK_SYMBOL_COMMENT("Measure some text or binary data into a PCR"),
|
||||
&vl_method_Extend,
|
||||
SD_VARLINK_SYMBOL_COMMENT("Event type to store in event log"),
|
||||
&vl_type_EventType,
|
||||
&vl_error_NoSuchNvPCR);
|
||||
|
||||
@@ -405,6 +405,11 @@ executables += [
|
||||
'dependencies' : libopenssl,
|
||||
'conditions' : ['HAVE_OPENSSL'],
|
||||
},
|
||||
test_template + {
|
||||
'sources' : files('test-pkcs7-util.c'),
|
||||
'dependencies' : libopenssl,
|
||||
'conditions' : ['HAVE_OPENSSL'],
|
||||
},
|
||||
test_template + {
|
||||
'sources' : files('test-parse-util.c'),
|
||||
'dependencies' : libm,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/* SPDX-License-Identifier: LGPL-2.1-or-later */
|
||||
|
||||
#include "hexdecoct.h"
|
||||
#include "iovec-util.h"
|
||||
#include "pkcs7-util.h"
|
||||
#include "tests.h"
|
||||
|
||||
TEST(pkcs7_extract_signers) {
|
||||
|
||||
const char tsig[] =
|
||||
"MIIEgwYJKoZIhvcNAQcCoIIEdDCCBHACAQExDzANBglghkgBZQMEAgEFADALBgkqhkiG9w0BBwGgggLp"
|
||||
"MIIC5TCCAc2gAwIBAgIURlvlj5ak0ZhvNS8hENNKwVv60x0wDQYJKoZIhvcNAQELBQAwGzEZMBcGA1UE"
|
||||
"AwwQbWtvc2kgb2YgbGVubmFydDAeFw0yNTAyMDMxMTAwMjNaFw0yNzAyMDMxMTAwMjNaMBsxGTAXBgNV"
|
||||
"BAMMEG1rb3NpIG9mIGxlbm5hcnQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCldQdmHVgU"
|
||||
"m4saaSqEpF1EVRf9pcIxpVShROBGAbxpxs4BQ7vV7zCg5bXwEVaqaENlIyzqPAj+YQifQS3Dj6LQfH3i"
|
||||
"War+ciKAv4PYcESG+pcdBb2kJOS8cVD6abZlO9rInvdhXK6PhYF7VohMwMPj/yJYdO50skwA5OQsCHO6"
|
||||
"amowh0tVzNzpbaJZg6wWIyTf3+ZzZdnOl5EvBCaUqeUhbaxRV9SrAw51rzAOnndUhW1we/vVKEGAPk3c"
|
||||
"SCb0LLPBG20XX2C00UXgnCWBkU5rkq6BnWSuInhFKCa48avstIet1ZFBA5T5J83ncU9UOVFksEYBXFoc"
|
||||
"lzcmcbx/2/oFAgMBAAGjITAfMB0GA1UdDgQWBBQewILFCixwq2rejOvJqmZSug2BBDANBgkqhkiG9w0B"
|
||||
"AQsFAAOCAQEAIvaeNPaJUoIUN5lQC/kcCiKeys96WNRGL2wbTp5PqdnRw14sbWY5iC2z13ih3dmTI9NF"
|
||||
"TBa7C/ji+5BaAfiJF17LOV00Y8eP5V94fHz4isb0sv5RzLsE4h8X7QFk4JBdV5GiCDzPXjxQAx9kM2so"
|
||||
"9RGtL8EhHpNygYDgyZ18YeiwcUPkCXT+xG2rM6s/Xlsji0s/18ycI4G8AC8dj5HycyS9BiZHgKrkgqTb"
|
||||
"VPo4zHYzhZdh0Qrd0J4YpoaotzQ35bkH9PtIkF6C7mE1Z7uMSGFkGQASgJ0BDTpM8QPAf2HIR2xxEtJR"
|
||||
"ZXkwxxdC+W9AJAzqJldmCHYGSrSR54J0rDGCAV4wggFaAgEBMDMwGzEZMBcGA1UEAwwQbWtvc2kgb2Yg"
|
||||
"bGVubmFydAIURlvlj5ak0ZhvNS8hENNKwVv60x0wDQYJYIZIAWUDBAIBBQAwDQYJKoZIhvcNAQEBBQAE"
|
||||
"ggEAXccqvpiEWsz/xvuLhINVZKIOznVdqjkERbZSqCBK94BYESSd+cijaB4XbYaFUZ45Bb3uUDQ56Ojq"
|
||||
"WoY1elEfqPyCb4vc887QoHmxI0BtdIaHhIDfCGBxhX8fwMknxqjgFa9YvONmDtv4QG4syTw+U3SEqBaa"
|
||||
"Avftqaa4v4eLk4uZ0nMIgMkx4qOlaxknpP404/nyZPANkOIwDxviNtRBCN9zSiPSqo1zre1vqzaM57Ww"
|
||||
"8zJASsPEzNR7OsPoLaIZv2OHXpowsRB78TuXGkQnm74T6xdG6DNs24jTYJuCPfGuYLHbrytdhXpFBS6m"
|
||||
"Orz9715jK2NU5VvGhNVXX4chcw==";
|
||||
|
||||
_cleanup_free_ void *sig = NULL;
|
||||
size_t siglen;
|
||||
ASSERT_OK(unbase64mem(tsig, &sig, &siglen));
|
||||
|
||||
size_t n_signers = 0;
|
||||
Signer *signers = NULL;
|
||||
CLEANUP_ARRAY(signers, n_signers, signer_free_many);
|
||||
|
||||
ASSERT_OK_EQ(pkcs7_extract_signers(&IOVEC_MAKE(sig, siglen), &signers, &n_signers), 1);
|
||||
ASSERT_EQ(n_signers, 1U);
|
||||
ASSERT_EQ(signers[0].issuer.iov_len, 29U);
|
||||
ASSERT_EQ(signers[0].serial.iov_len, 22U);
|
||||
|
||||
_cleanup_free_ char *issuer = NULL;
|
||||
ASSERT_OK(base64mem(signers[0].issuer.iov_base, signers[0].issuer.iov_len, &issuer));
|
||||
|
||||
_cleanup_free_ char *serial = NULL;
|
||||
ASSERT_OK(base64mem(signers[0].serial.iov_base, signers[0].serial.iov_len, &serial));
|
||||
|
||||
ASSERT_STREQ(issuer, "MBsxGTAXBgNVBAMMEG1rb3NpIG9mIGxlbm5hcnQ=");
|
||||
ASSERT_STREQ(serial, "AhRGW+WPlqTRmG81LyEQ00rBW/rTHQ==");
|
||||
}
|
||||
|
||||
DEFINE_TEST_MAIN(LOG_INFO);
|
||||
@@ -31,12 +31,12 @@ executables += [
|
||||
'HAVE_TPM2',
|
||||
],
|
||||
},
|
||||
|
||||
]
|
||||
|
||||
if conf.get('ENABLE_BOOTLOADER') == 1 and conf.get('HAVE_OPENSSL') == 1 and conf.get('HAVE_TPM2') == 1
|
||||
nvpcrs = [ 'cryptsetup',
|
||||
'hardware' ]
|
||||
'hardware',
|
||||
'verity']
|
||||
foreach n : nvpcrs
|
||||
custom_target(
|
||||
input : 'nvpcr/' + n + '.nvpcr.in',
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name" : "verity",
|
||||
"algorithm" : "sha256",
|
||||
"nvIndex" : {{TPM2_NVPCR_BASE + 2}}
|
||||
}
|
||||
@@ -16,9 +16,11 @@
|
||||
#include "main-func.h"
|
||||
#include "parse-util.h"
|
||||
#include "path-util.h"
|
||||
#include "pcrextend-util.h"
|
||||
#include "pretty-print.h"
|
||||
#include "string-util.h"
|
||||
#include "strv.h"
|
||||
#include "tpm2-util.h"
|
||||
#include "verbs.h"
|
||||
|
||||
static char *arg_hash = NULL; /* the hash algorithm */
|
||||
@@ -38,12 +40,14 @@ static uint64_t arg_fec_roots = 2;
|
||||
static void *arg_root_hash_signature = NULL;
|
||||
static size_t arg_root_hash_signature_size = 0;
|
||||
static bool arg_root_hash_signature_auto = false;
|
||||
static char *arg_tpm2_measure_nvpcr = NULL;
|
||||
|
||||
STATIC_DESTRUCTOR_REGISTER(arg_hash, freep);
|
||||
STATIC_DESTRUCTOR_REGISTER(arg_salt, freep);
|
||||
STATIC_DESTRUCTOR_REGISTER(arg_uuid, freep);
|
||||
STATIC_DESTRUCTOR_REGISTER(arg_fec_what, freep);
|
||||
STATIC_DESTRUCTOR_REGISTER(arg_root_hash_signature, freep);
|
||||
STATIC_DESTRUCTOR_REGISTER(arg_tpm2_measure_nvpcr, freep);
|
||||
|
||||
static int help(void) {
|
||||
_cleanup_free_ char *link = NULL;
|
||||
@@ -142,7 +146,7 @@ static int parse_options(const char *options) {
|
||||
|
||||
for (;;) {
|
||||
_cleanup_free_ char *word = NULL;
|
||||
char *val;
|
||||
const char *val;
|
||||
|
||||
r = extract_first_word(&options, &word, ",", EXTRACT_DONT_COALESCE_SEPARATORS | EXTRACT_UNESCAPE_SEPARATORS);
|
||||
if (r < 0)
|
||||
@@ -290,6 +294,21 @@ static int parse_options(const char *options) {
|
||||
if (r < 0)
|
||||
return r;
|
||||
|
||||
} else if ((val = startswith(word, "tpm2-measure-nvpcr="))) {
|
||||
r = isempty(val) ? 0 : parse_boolean(val);
|
||||
if (r == 0) {
|
||||
arg_tpm2_measure_nvpcr = mfree(arg_tpm2_measure_nvpcr);
|
||||
return 0;
|
||||
}
|
||||
if (r > 0)
|
||||
val = "verity";
|
||||
else if (!tpm2_nvpcr_name_is_valid(val)) {
|
||||
log_warning("Invalid NvPCR name, ignoring: %s", word);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (free_and_strdup(&arg_tpm2_measure_nvpcr, val) < 0)
|
||||
return log_oom();
|
||||
} else
|
||||
log_warning("Encountered unknown option '%s', ignoring.", word);
|
||||
}
|
||||
@@ -423,6 +442,11 @@ static int verb_attach(int argc, char *argv[], void *userdata) {
|
||||
if (r < 0)
|
||||
return log_error_errno(r, "Failed to set up verity device '%s': %m", volume);
|
||||
|
||||
(void) pcrextend_verity_now(
|
||||
volume,
|
||||
&IOVEC_MAKE(rh, rh_size),
|
||||
&IOVEC_MAKE(arg_root_hash_signature, arg_root_hash_signature_size));
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,8 @@ at_exit() {
|
||||
jq --seq --slurp </run/log/systemd/tpm2-measure.log
|
||||
fi
|
||||
|
||||
rm -rf /run/nvpcr
|
||||
rm -rf /run/nvpcr /tmp/nvpcr
|
||||
rm -f /var/tmp/nvpcr.raw /run/verity.d/test-79-nvpcr.crt
|
||||
}
|
||||
|
||||
trap at_exit EXIT
|
||||
@@ -52,3 +53,46 @@ test "$DIGEST_ACTUAL2" != "$DIGEST_EXPECTED"
|
||||
DIGEST_MEASURED2="$(echo -n "schnurz" | openssl dgst -sha256 -hex -r | cut -d' ' -f1)"
|
||||
DIGEST_EXPECTED2="$(echo "$DIGEST_EXPECTED$DIGEST_MEASURED2" | tr '[:lower:]' '[:upper:]' | basenc --base16 -d | openssl dgst -sha256 -hex -r | cut -d' ' -f1)"
|
||||
test "$DIGEST_ACTUAL2" = "$DIGEST_EXPECTED2"
|
||||
|
||||
mkdir /tmp/nvpcr
|
||||
|
||||
OPENSSL_CONFIG="/tmp/nvpcr/opensslconfig"
|
||||
# Unfortunately OpenSSL insists on reading some config file, hence provide one with mostly placeholder contents
|
||||
cat >"${OPENSSL_CONFIG:?}" <<EOF
|
||||
[ req ]
|
||||
prompt = no
|
||||
distinguished_name = req_distinguished_name
|
||||
|
||||
[ req_distinguished_name ]
|
||||
C = DE
|
||||
ST = Test State
|
||||
L = Test Locality
|
||||
O = Org Name
|
||||
OU = Org Unit Name
|
||||
CN = Common Name
|
||||
emailAddress = test@email.com
|
||||
EOF
|
||||
|
||||
openssl req -config "$OPENSSL_CONFIG" -subj="/CN=waldo" \
|
||||
-x509 -sha256 -nodes -days 365 -newkey rsa:4096 \
|
||||
-keyout /tmp/nvpcr/test-70-nvpcr.key -out /tmp/nvpcr/test-70-nvpcr.crt
|
||||
|
||||
mkdir /tmp/nvpcr/tree
|
||||
touch /tmp/nvpcr/tree/file
|
||||
|
||||
SYSTEMD_REPART_OVERRIDE_FSTYPE=squashfs \
|
||||
systemd-repart -P \
|
||||
-s /tmp/nvpcr/tree \
|
||||
--certificate=/tmp/nvpcr/test-70-nvpcr.crt \
|
||||
--private-key=/tmp/nvpcr/test-70-nvpcr.key \
|
||||
/var/tmp/nvpcr.raw
|
||||
|
||||
mkdir -p /run/verity.d
|
||||
cp /tmp/nvpcr/test-70-nvpcr.crt /run/verity.d/
|
||||
|
||||
cp /run/log/systemd/tpm2-measure.log /tmp/nvpcr/log-before
|
||||
|
||||
systemd-dissect --image-policy='root=signed:=absent+unused' --mtree /var/tmp/nvpcr.raw
|
||||
|
||||
set +o pipefail
|
||||
diff /tmp/nvpcr/log-before /run/log/systemd/tpm2-measure.log | grep -F '"content":{"nvIndexName":"verity","string":"verity:'
|
||||
|
||||
Reference in New Issue
Block a user