Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ The `imapcrutils` module consists of the following public types and functions:
| `build_template_fields` | Build `ima-ng` template fields (digest/name) from an `IMALogEntry`. |
| `calculate_expected_template_hash` | Recompute the expected template hash for an entry (default: SHA-1). |
| `calculate_pcr10` | Replay PCR10 by extending PCR10 with each `ima-ng` entry (default chain hash: SHA-256). |
| `truncate_ima_log_by_pcr` | Truncate the IMA log at the point where the calculated PCR matches the reference value. |
| `validate_ima_log_entry` | Validate a single entry by comparing the template hash with the recomputed value. |
| `calculate_boot_aggregate` | Calculate `boot_aggregate` from PCR0..PCR9 values. |

Expand All @@ -45,6 +46,7 @@ and command-line tools. Sample IMA log and PCR list files are also available.
| Script | Description |
| ------ | ----------- |
| `pcr10.py` | Calculate PCR10 from input IMA log |
| `truncate_log.py` | Truncate IMA log at the point where the calculated PCR matches the reference value |
| `boot_aggregate.py` | Calculate boot_aggregate from PCR list file including PCR[0-9] |

### Compare with the true PCR10 hash value
Expand Down
42 changes: 42 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,48 @@ python pcr10.py
python pcr10.py -i ascii_runtime_measurements
```

## truncate_log

### Usage

```shell
python truncate_log.py [-i $IMA_LOG_PATH] -p $PCR_HEX [-a $HASH_ALGORITHM] [-o $OUTPUT_PATH]
```

```shell
python truncate_log.py [--in $IMA_LOG_PATH] --pcr10 $PCR_HEX [--hash-algorithm $HASH_ALGORITHM] [--out $OUTPUT_PATH]
```

### Options

- `-i, --in`: Path to the IMA log file (default: `/sys/kernel/security/ima/ascii_runtime_measurements`)
- `-p, --pcr`: Reference PCR value in hex format (required)
- `-a, --hash-algorithm`: Hash algorithm used for PCR calculation: `sha1`, `sha256`, `sha384`, `sha512` (default: `sha256`)
- `-o, --out`: Path to the output file (default: stdout)

### Description

This tool truncates an IMA log to find the point where the calculated PCR value
matches a reference PCR value. It's useful for identifying which IMA log entries
are relevant to a particular PCR measurement from a TPM.

The function filters entries for PCR index "10" and "ima-ng" template only, then
extends the PCR value incrementally until it finds a match with the reference.
Returns the sublist of entries from the beginning up to and including
the matching entry.

### Example

```shell
# Truncate IMA log using reference PCR10 from pcrlist_2.bin
python truncate_pcr10.py -i ascii_runtime_measurements_2 -p c5bfcd40187bfc190fe9c584b8b2675f08180c0e9579255fa9eba91e7d18f678

# Save truncated log to file
python truncate_pcr10.py -i ascii_runtime_measurements_2 \
-p c5bfcd40187bfc190fe9c584b8b2675f08180c0e9579255fa9eba91e7d18f678 \
-o truncated_measurements.txt
```

## boot-aggregate

### Usage
Expand Down
514 changes: 514 additions & 0 deletions examples/ascii_runtime_measurements_2

Large diffs are not rendered by default.

Binary file added examples/pcrlist_2.bin
Binary file not shown.
134 changes: 134 additions & 0 deletions examples/truncate_log.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
"""
CLI tool for truncating IMA log entries to find the point where PCR matches a reference value.
"""

import argparse
import hashlib
import sys

from imapcrutils import parse_ima_log_string, truncate_ima_log_by_pcr

DEFAULT_IMA_LOG_PATH = "/sys/kernel/security/ima/ascii_runtime_measurements"


def select_hash_function(hash_algorithm: str):
"""
Select hash function based on the hash algorithm.
"""
match hash_algorithm:
case "sha1":
return hashlib.sha1
case "sha256":
return hashlib.sha256
case "sha384":
return hashlib.sha384
case "sha512":
return hashlib.sha512
case _:
# will not reach here (choices enforced by argparse)
raise ValueError(f"Invalid hash algorithm: {hash_algorithm}")


def output_truncated_log(entries: list, output_path: str | None) -> None:
"""
Output truncated IMA log entries to file or stdout.
"""
output_lines = [str(entry) for entry in entries]
output_text = "\n".join(output_lines) + "\n"
if output_path is None:
print(output_text, end="")
else:
with open(output_path, "w", encoding="utf-8") as f:
f.write(output_text)


def main() -> int:
"""
Main function.
"""
parser = argparse.ArgumentParser(description="Truncate IMA log to find the point where PCR matches a reference value.")
parser.add_argument(
"-i",
"--in",
dest="input_path",
default=DEFAULT_IMA_LOG_PATH,
help=f"Path to the IMA log file (default: {DEFAULT_IMA_LOG_PATH})",
)
parser.add_argument(
"-p",
"--pcr",
dest="pcr_hex",
required=True,
help="Reference PCR value in hex format (e.g., c5bfcd40187bfc190fe9c584b8b2675f08180c0e9579255fa9eba91e7d18f678)",
)
parser.add_argument(
"-a",
"--hash-algorithm",
dest="hash_algorithm",
type=str.lower,
default="sha256",
choices=["sha1", "sha256", "sha384", "sha512"],
help="Hash algorithm used for PCR calculation (default: sha256)",
)
parser.add_argument(
"-o",
"--out",
dest="output_path",
default=None,
help="Path to the output file (default: stdout)",
)

args = parser.parse_args()

# Parse the hex-encoded PCR reference
try:
pcr_reference = bytes.fromhex(args.pcr_hex)
except ValueError:
print(f"Error: Invalid hex value for PCR10: {args.pcr_hex}", file=sys.stderr)
return 1

# Verify the PCR value has the correct length based on hash algorithm
hash_func = select_hash_function(args.hash_algorithm)
expected_length = hash_func().digest_size
if len(pcr_reference) != expected_length:
print(
f"Error: PCR10 length mismatch. Expected {expected_length} bytes for {args.hash_algorithm}, "
f"got {len(pcr_reference)} bytes",
file=sys.stderr,
)
return 1

# Read IMA log entries
try:
with open(args.input_path, encoding="utf-8") as f:
lines = f.read()
except FileNotFoundError:
print(f"Error: IMA log file not found: {args.input_path}", file=sys.stderr)
return 1
except OSError as e:
print(f"Error reading IMA log file: {e}", file=sys.stderr)
return 1

entries = parse_ima_log_string(lines)

# Truncate the log to find the matching PCR
result = truncate_ima_log_by_pcr(entries, pcr_reference, hash_func)

if result is None:
print(
"Error: The reference PCR10 does not match any point in the IMA log.",
file=sys.stderr,
)
return 1

# Output the truncated log
output_truncated_log(result, args.output_path)
print(f"Successfully truncated IMA log to {len(result)} entries", file=sys.stderr)

return 0


if __name__ == "__main__":
sys.exit(main())
1 change: 1 addition & 0 deletions imapcrutils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@
calculate_expected_template_hash,
calculate_pcr10,
parse_ima_log_string,
truncate_ima_log_by_pcr,
validate_ima_log_entry,
)
36 changes: 36 additions & 0 deletions imapcrutils/libs.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"build_template_fields",
"calculate_expected_template_hash",
"calculate_pcr10",
"truncate_ima_log_by_pcr",
"validate_ima_log_entry",
"calculate_boot_aggregate",
]
Expand Down Expand Up @@ -165,6 +166,41 @@ def calculate_pcr10(entries: list[IMALogEntry], hash_func: Callable[[bytes], byt
return pcr_value


def truncate_ima_log_by_pcr(
entries: list[IMALogEntry], pcr: bytes, hash_func: Callable[[bytes], bytes] = hashlib.sha256
) -> list[IMALogEntry] | None:
"""
Find the point in the IMA log where the calculated PCR10 matches the reference value.

Filters IMA log entries for PCR index "10" and "ima-ng" template, then extends
the PCR value incrementally. Returns the sublist of valid entries from the
beginning up to and including the entry where the PCR value matches the reference.
Returns None if no match is found.

Args:
entries: List of IMALogEntry objects to process
pcr: Reference PCR value to match against
hash_func: Hash function for PCR extension (default: hashlib.sha256)

Returns:
List of IMALogEntry objects from the beginning up to the matching entry,
or None if the reference PCR value is not found
"""
results = []
pcr_value = bytes(hash_func().digest_size)
for entry in entries:
if entry.pcr_idx != "10":
continue
if entry.template_name != "ima-ng":
continue
results.append(entry)
template_hash = calculate_expected_template_hash(entry, hash_func)
pcr_value = hash_func(pcr_value + template_hash).digest()
if pcr_value == pcr:
return results
return None


def validate_ima_log_entry(entry: IMALogEntry, hash_func: Callable[[bytes], bytes] = hashlib.sha1) -> bool:
"""
Validate IMA log entry. Template_hash must coincide with the hash of the file data.
Expand Down
24 changes: 24 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,27 @@ def sample_pcr_list_path():
def sample_pcr_blob(sample_pcr_list_path):
"""Raw bytes of the sample PCR list blob (concatenated SHA-256 digests for 14 PCRs)."""
return sample_pcr_list_path.read_bytes()


@pytest.fixture
def sample_ima_log_2_path():
"""Path to the second sample ascii_runtime_measurements_2 file."""
return EXAMPLES_DIR / "ascii_runtime_measurements_2"


@pytest.fixture
def sample_ima_log_2(sample_ima_log_2_path):
"""Contents of the second sample IMA log as a string."""
return sample_ima_log_2_path.read_text()


@pytest.fixture
def sample_pcr_list_2_path():
"""Path to the second sample pcrlist_2.bin file."""
return EXAMPLES_DIR / "pcrlist_2.bin"


@pytest.fixture
def sample_pcr_blob_2(sample_pcr_list_2_path):
"""Raw bytes of the second sample PCR list blob (concatenated SHA-256 digests for 24 PCRs)."""
return sample_pcr_list_2_path.read_bytes()
90 changes: 90 additions & 0 deletions tests/test_libs.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
calculate_expected_template_hash,
calculate_pcr10,
parse_ima_log_string,
truncate_ima_log_by_pcr,
validate_ima_log_entry,
)

Expand Down Expand Up @@ -301,3 +302,92 @@ def test_all_zeros(self):
ba = calculate_boot_aggregate(pcr_list, hashlib.sha256)
expected = hashlib.sha256(bytes(320)).digest()
assert ba == expected


# ---------------------------------------------------------------------------
# truncate_ima_log_by_pcr
# ---------------------------------------------------------------------------

EXPECTED_PCR10_SHA256_2 = "c5bfcd40187bfc190fe9c584b8b2675f08180c0e9579255fa9eba91e7d18f678"


class TestTruncateImaLogByPcr:
"""Tests for truncate_ima_log_by_pcr — finding the matching PCR10 point."""

def test_truncate_with_sample_2(self, sample_ima_log_2, sample_pcr_blob_2):
"""Truncate IMA log to find the matching PCR10 from sample_2."""
entries = parse_ima_log_string(sample_ima_log_2)
pcr10_reference = sample_pcr_blob_2[10 * 32 : 11 * 32]
result = truncate_ima_log_by_pcr(entries, pcr10_reference, hashlib.sha256)
assert result is not None
# Verify the truncated list has PCR10 matching the reference
computed_pcr10 = calculate_pcr10(result, hashlib.sha256)
assert computed_pcr10 == pcr10_reference

def test_truncate_returns_sublist(self, sample_ima_log_2, sample_pcr_blob_2):
"""Truncated result must be a sublist from the beginning."""
entries = parse_ima_log_string(sample_ima_log_2)
pcr10_reference = sample_pcr_blob_2[10 * 32 : 11 * 32]
result = truncate_ima_log_by_pcr(entries, pcr10_reference, hashlib.sha256)
assert result is not None
# Result must be a prefix of the original entries (same sequence from start)
assert result == entries[: len(result)]
# First entry must be the same
assert result[0].file_path == entries[0].file_path

def test_truncate_not_found_returns_none(self, sample_ima_log):
"""Truncate with a non-matching PCR10 reference must return None."""
entries = parse_ima_log_string(sample_ima_log)
# Use a random PCR10 value that won't match
fake_pcr10 = b"\x00" * 32
result = truncate_ima_log_by_pcr(entries, fake_pcr10, hashlib.sha256)
assert result is None

def test_truncate_empty_entries_returns_none(self, sample_pcr_blob_2):
"""Truncate with empty entries must return None."""
pcr10_reference = sample_pcr_blob_2[10 * 32 : 11 * 32]
result = truncate_ima_log_by_pcr([], pcr10_reference, hashlib.sha256)
assert result is None

def test_truncate_single_entry_match(self):
"""Truncate with a single entry that matches must return that entry."""
entries = parse_ima_log_string(SAMPLE_LINE)
# Calculate what the PCR10 would be with just this one entry
expected_template = calculate_expected_template_hash(entries[0], hashlib.sha256)
pcr10_reference = hashlib.sha256(bytes(32) + expected_template).digest()
result = truncate_ima_log_by_pcr(entries, pcr10_reference, hashlib.sha256)
assert result is not None
assert len(result) == 1
assert result[0].file_path == "boot_aggregate"

def test_truncate_pcr10_indices_only(self, sample_ima_log_2, sample_pcr_blob_2):
"""Only PCR 10 entries should be included in the calculation."""
entries = parse_ima_log_string(sample_ima_log_2)
# Filter to only PCR10 entries
pcr10_reference = sample_pcr_blob_2[10 * 32 : 11 * 32]
result = truncate_ima_log_by_pcr(entries, pcr10_reference, hashlib.sha256)
assert result is not None
# All entries in result must be PCR10
for entry in result:
assert entry.pcr_idx == "10"

def test_truncate_ima_ng_template_only(self, sample_ima_log_2, sample_pcr_blob_2):
"""Only ima-ng template entries should be included."""
entries = parse_ima_log_string(sample_ima_log_2)
pcr10_reference = sample_pcr_blob_2[10 * 32 : 11 * 32]
result = truncate_ima_log_by_pcr(entries, pcr10_reference, hashlib.sha256)
assert result is not None
# All entries in result must be ima-ng
for entry in result:
assert entry.template_name == "ima-ng"

def test_truncate_with_sha1(self, sample_ima_log):
"""Truncate must work with SHA-1 hash function."""
entries = parse_ima_log_string(sample_ima_log)
# Calculate expected PCR10 with SHA-1
pcr10_sha1 = calculate_pcr10(entries, hashlib.sha1)
# Now use truncate to find it
result = truncate_ima_log_by_pcr(entries, pcr10_sha1, hashlib.sha1)
assert result is not None
# The entire log should match (the final PCR10)
assert len(result) == len(entries)
Loading