mirror of
https://github.com/systemd/systemd.git
synced 2026-08-09 01:20:53 +00:00
The current TPM2 PIN mode is flawed as a compromised TPM directly exposes the sealed secret which is the LUKS volume key itself (#27502 and #37386). Goal: add Argon2id-based PIN hardening to TPM2 enrollment, making the TPM a second factor rather than a single point of failure: 1. Password + salt -> Argon2id -> 512-bit key split into Key1 + Key2 2. Key2 (base64-encoded) is used as the PIN to seal a random secret in the TPM 3. Key1 + unsealed secret -> HKDF-SHA256 -> final LUKS volume key This implementation ensures that if the TPM is compromised, an attacker still needs the password to derive Key1 and combine it with the unsealed secret. The --tpm2-with-pin= option now accepts three values: - false (no PIN used) - true (PIN hardened with Argon2id - default) - "direct" (legacy PIN without Argon2id for backward compatibility) Argon2id parameters are customizable via: --tpm2-argon2id-memory= --tpm2-argon2id-iterations= --tpm2-argon2id-parallelism= --tpm2-argon2id-iter-time= These default to a function of available CPUs and physical memory, with a benchmark that scales iterations to the target time (default: 2s) and falls back to ARGON2ID_PARAMETERS_DEFAULT (64 MiB, 8 iter, 4 lanes) when auto detection fails. Also if the runtime OpenSSL lacks Argon2id support (< 3.2), the feature silently falls back to direct PIN mode with a warning. Added includes: - src/cryptenroll/cryptenroll.c: cpu-set-util.h, limits-util.h, time-util.h for Argon2id benchmark auto-tuning (cpus_online, physical_memory_scale, now/usec_t) - src/cryptenroll/cryptenroll-tpm2.c: crypto-util.h for Argon2IdParameters struct in load_volume_key_tpm2() - src/shared/tpm2-util.h: crypto-util.h for Argon2IdParameters in tpm2_make_luks2_json() API - src/shared/tpm2-util.c: limits-util.h, tpm2-util.h for physical_memory() validation of Argon2id memory cost and function prototypes - src/cryptsetup/cryptsetup-tokens/luks2-tpm2.c: crypto-util.h for kdf_argon2id_derive()/kdf_hkdf_sha256() on the token unlock path
295 lines
9.5 KiB
Python
Executable File
295 lines
9.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Compare statically linked dlopen symbols against shared library ELF notes.
|
|
|
|
This script extracts surviving dlopen_* symbols from a statically linked
|
|
executable (representing the true minimal set of dynamically loaded features
|
|
actually invoked by the code after linker optimizations like --gc-sections)
|
|
and compares them against the embedded FDO .note.dlopen metadata inside a
|
|
shared library or dynamic executable.
|
|
|
|
It highlights missing metadata, over-included (dead) metadata, and detects
|
|
any unmapped symbols that require script updates.
|
|
"""
|
|
|
|
import argparse
|
|
import enum
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
from functools import total_ordering
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
try:
|
|
from elftools.elf.elffile import ELFFile
|
|
from elftools.elf.sections import NoteSection, SymbolTableSection
|
|
except ImportError:
|
|
print("Error: 'elftools' python module not found.", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
# Function filter: Maps wrapper/sentinel symbols to real symbols or an empty list (ignore).
|
|
FUNCTION_FILTER = {
|
|
# Filtered out (internal helpers, verbose loggers, sentinels)
|
|
'dlopen_safe': [],
|
|
'dlopen_verbose': [],
|
|
'dlopen_many_sym_or_warn_sentinel': [],
|
|
'dlopen_dw_has_dwfl_set_sysroot': [],
|
|
'dlopen_libcrypto_has_argon2id': [],
|
|
# Mapped to multiple functions (expands generic wrappers into specific sub-layers)
|
|
'dlopen_tpm2': [
|
|
'dlopen_tpm2_esys',
|
|
'dlopen_tpm2_mu',
|
|
'dlopen_tpm2_rc',
|
|
'dlopen_tpm2_tcti_device',
|
|
],
|
|
}
|
|
|
|
|
|
# Direct mapping from clean symbol name to expected FDO dlopen feature name.
|
|
FUNCTION_TO_FEATURE = {
|
|
'dlopen_bpf': 'bpf',
|
|
'dlopen_bzip2': 'bzip2',
|
|
'dlopen_cryptsetup': 'cryptsetup',
|
|
'dlopen_curl': 'curl',
|
|
'dlopen_dw': 'dw',
|
|
'dlopen_elf': 'elf',
|
|
'dlopen_fdisk': 'fdisk',
|
|
'dlopen_gnutls': 'gnutls',
|
|
'dlopen_idn': 'idn',
|
|
'dlopen_libacl': 'acl',
|
|
'dlopen_libapparmor': 'apparmor',
|
|
'dlopen_libarchive': 'archive',
|
|
'dlopen_libaudit': 'audit',
|
|
'dlopen_libblkid': 'blkid',
|
|
'dlopen_libcrypt': 'crypt',
|
|
'dlopen_libcrypto': 'libcrypto',
|
|
'dlopen_libfido2': 'fido2',
|
|
'dlopen_libkmod': 'kmod',
|
|
'dlopen_libmount': 'mount',
|
|
'dlopen_libpam': 'pam',
|
|
'dlopen_libseccomp': 'seccomp',
|
|
'dlopen_libselinux': 'selinux',
|
|
'dlopen_libssl': 'libssl',
|
|
'dlopen_lz4': 'lz4',
|
|
'dlopen_microhttpd': 'microhttpd',
|
|
'dlopen_p11kit': 'p11-kit',
|
|
'dlopen_passwdqc': 'passwdqc',
|
|
'dlopen_pcre2': 'pcre2',
|
|
'dlopen_pwquality': 'pwquality',
|
|
'dlopen_qrencode': 'qrencode',
|
|
'dlopen_tpm2_esys': 'tpm (esys)',
|
|
'dlopen_tpm2_mu': 'tpm (mu)',
|
|
'dlopen_tpm2_rc': 'tpm (rc)',
|
|
'dlopen_tpm2_tcti_device': 'tpm (tcti-device)',
|
|
'dlopen_xkbcommon': 'xkbcommon',
|
|
'dlopen_xz': 'lzma',
|
|
'dlopen_zlib': 'zlib',
|
|
'dlopen_zstd': 'zstd',
|
|
}
|
|
|
|
|
|
def dlopen_function_append(name: str, function_names: set[str]) -> None:
|
|
"""Add a symbol to the set, expanding or dropping based on FUNCTION_FILTER."""
|
|
function_names.update(FUNCTION_FILTER.get(name, [name]))
|
|
|
|
|
|
def dlopen_functions_parse(path: Path) -> set[str]:
|
|
"""Parse ELF symbol tables and extract surviving dlopen_* function names."""
|
|
function_names: set[str] = set()
|
|
|
|
try:
|
|
with path.open('rb') as f:
|
|
elffile = ELFFile(f)
|
|
for section in elffile.iter_sections():
|
|
if not isinstance(section, SymbolTableSection):
|
|
continue
|
|
for symbol in section.iter_symbols():
|
|
# Ignore undefined (externally referenced) or non-function symbols
|
|
if symbol['st_info']['type'] != 'STT_FUNC' or symbol['st_shndx'] == 'SHN_UNDEF':
|
|
continue
|
|
|
|
if m := re.match(r'^(?:sym_)?(dlopen_[a-zA-Z0-9_]+)$', symbol.name):
|
|
dlopen_function_append(m.group(1), function_names)
|
|
|
|
except Exception as e:
|
|
print(f"Failed to read '{path}': {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
return function_names
|
|
|
|
|
|
def dlopen_note_get_feature(item: dict[str, Any]) -> str:
|
|
if 'feature' not in item:
|
|
return None
|
|
|
|
feature = item['feature']
|
|
if feature != 'tpm':
|
|
return feature
|
|
|
|
# TPM features are collapsed into "tpm" in the ELF note. We iterate over
|
|
# sonames to correctly map them back to detailed granular layers.
|
|
for soname in item.get('soname', []):
|
|
if 'esys' in soname:
|
|
return 'tpm (esys)'
|
|
if 'mu' in soname:
|
|
return 'tpm (mu)'
|
|
if 'rc' in soname:
|
|
return 'tpm (rc)'
|
|
if 'tcti-device' in soname:
|
|
return 'tpm (tcti-device)'
|
|
else:
|
|
return feature # If not found, let's return 'tpm' as is, and warn later.
|
|
|
|
|
|
@total_ordering
|
|
class Priority(enum.Enum):
|
|
suggested = 1
|
|
recommended = 2
|
|
required = 3
|
|
|
|
def __lt__(self, other):
|
|
return self.value < other.value
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
|
|
def dlopen_note_append(item: dict[str, Any], entries: dict[str, dict[str, Any]]) -> None:
|
|
"""Store the parsed note item into the dictionary."""
|
|
|
|
feat = dlopen_note_get_feature(item)
|
|
prio = Priority[item.get('priority', 'recommended')]
|
|
|
|
if feat in entries and Priority[entries[feat].get('priority', 'recommended')] >= prio:
|
|
return
|
|
|
|
entries[feat] = item
|
|
|
|
|
|
def dlopen_note_merge(
|
|
a: dict[str, dict[str, Any]],
|
|
b: dict[str, dict[str, Any]],
|
|
) -> dict[str, dict[str, Any]]:
|
|
c = a
|
|
|
|
for item in b.values():
|
|
dlopen_note_append(item, c)
|
|
|
|
return c
|
|
|
|
|
|
def dlopen_note_parse(path: Path) -> dict[str, dict[str, Any]]:
|
|
"""Parse .note.dlopen ELF section and extract embedded JSON metadata."""
|
|
entries: dict[str, dict[str, Any]] = {}
|
|
|
|
try:
|
|
with path.open('rb') as f:
|
|
elffile = ELFFile(f)
|
|
for section in elffile.iter_sections():
|
|
# Verify section name and type safely
|
|
if section.name != '.note.dlopen' or not isinstance(section, NoteSection):
|
|
continue
|
|
|
|
for note in section.iter_notes():
|
|
desc = note['n_desc']
|
|
# Handle raw bytes or string payloads, stripping null-terminator bytes (\x00)
|
|
desc_str = (
|
|
desc.decode('utf-8', errors='ignore').rstrip('\x00')
|
|
if isinstance(desc, bytes)
|
|
else str(desc).rstrip('\x00')
|
|
)
|
|
|
|
try:
|
|
data = json.loads(desc_str)
|
|
# According to FDO specs, payload can be a JSON array or a single dict
|
|
items = data if isinstance(data, list) else [data]
|
|
for item in items:
|
|
if isinstance(item, dict):
|
|
dlopen_note_append(item, entries)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
|
|
except Exception as e:
|
|
print(f"Failed to read '{path}': {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
return entries
|
|
|
|
|
|
def dlopen_note_dump(entries: dict[str, dict[str, Any]], path: str) -> None:
|
|
print(f'=== Embedded .note.dlopen Metadata in: {path} ===')
|
|
if not entries:
|
|
print(' (No .note.dlopen metadata found)')
|
|
else:
|
|
for feat, item in sorted(entries.items()):
|
|
p = item.get('priority', 'recommended')
|
|
print(f" - {feat.ljust(20)} : {p.ljust(15)} : {' '.join(item.get('soname', []))}")
|
|
print()
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description='Compare dlopen symbols vs metadata notes.')
|
|
parser.add_argument('--static', required=True, help='Path to statically linked binary')
|
|
parser.add_argument('--shared', required=True, help='Path to shared library or dynamic binary')
|
|
args = parser.parse_args()
|
|
|
|
unknown_functions = set()
|
|
features_by_func = set()
|
|
if check_function_symbol := os.getenv('SYSTEMD_BUILD_MODE') == 'developer':
|
|
for name in dlopen_functions_parse(Path(args.static)):
|
|
if name not in FUNCTION_TO_FEATURE:
|
|
unknown_functions.add(name)
|
|
else:
|
|
features_by_func.add(FUNCTION_TO_FEATURE[name])
|
|
|
|
note_static = dlopen_note_parse(Path(args.static))
|
|
note_shared = dlopen_note_parse(Path(args.shared))
|
|
note_merged = dlopen_note_merge(note_static, note_shared)
|
|
|
|
dlopen_note_dump(note_static, args.static)
|
|
dlopen_note_dump(note_shared, args.shared)
|
|
|
|
print('=== [DIFF] Analysis & Verification ===')
|
|
|
|
rc = 0
|
|
|
|
for feat, item in sorted(note_merged.items()):
|
|
if feat not in note_shared:
|
|
print(f' ❌ {feat} is missing.')
|
|
rc = 1
|
|
continue
|
|
|
|
p = Priority[item.get('priority', 'recommended')]
|
|
q = Priority[note_shared[feat].get('priority', 'recommended')]
|
|
if q < p:
|
|
print(f' ⚠️ {feat} has weaker priority ({q} < {p}).')
|
|
rc = 1
|
|
continue
|
|
|
|
if check_function_symbol and feat not in features_by_func:
|
|
print(f' 💥 {feat} is set, but corresponding dlopen() is not called.')
|
|
rc = 1
|
|
continue
|
|
|
|
if feat not in note_static:
|
|
print(f' 👀 {feat} is missing in {args.static}.')
|
|
rc = 1
|
|
|
|
if unknown_functions:
|
|
print(' 💀 [UNKNOWN DLOPEN FUNCTIONS] Found unknown dlopen functions, please update tables:')
|
|
for f in sorted(unknown_functions):
|
|
print(f" * Function '{f}'")
|
|
rc = 1
|
|
|
|
if rc == 0:
|
|
print(' 🎉 PERFECT MATCH! Exactly required dlopen notes are embedded.')
|
|
|
|
sys.exit(rc)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|