stages: add new stage org.osbuild.hmac

The new org.osbuild.hmac stage can be used to calculate hmac digests to
be stored alongside files for verification.
This commit is contained in:
Achilleas Koutsou 2025-03-28 17:08:41 +01:00 committed by Tomáš Hozza
parent 7b843dc83e
commit 5b77ff6f65
3 changed files with 170 additions and 0 deletions

34
stages/org.osbuild.hmac Executable file
View file

@ -0,0 +1,34 @@
#!/usr/bin/python3
import os
import subprocess
import sys
import osbuild.api
def hmac_filepath(path):
directory = os.path.dirname(path)
basename = os.path.basename(path)
return os.path.join(directory, f".{basename}.hmac")
def main(tree, options):
paths = options["paths"]
algorithm = options["algorithm"]
hmac_cmd = f"{algorithm}hmac"
for path in paths:
real_path = os.path.join(tree, path.lstrip("/"))
with open(hmac_filepath(real_path), "w", encoding="utf-8") as hmac_file:
# run from the directory of the target file to create the hmac file with just the basename
cwd = os.path.dirname(real_path)
base = os.path.basename(real_path)
subprocess.run([hmac_cmd, base], cwd=cwd, encoding="utf-8", stdout=hmac_file, check=True)
return 0
if __name__ == '__main__':
args = osbuild.api.arguments()
r = main(args["tree"], args["options"])
sys.exit(r)

View file

@ -0,0 +1,45 @@
{
"summary": "Generates .hmac checksum files",
"description": [
"Generates HMAC values for given files and stores them alongside the original",
"for later verification.",
"The stage uses the <algorithm>hmac commands (e.g. sha512hmac) which use a",
"built-in key when none is provided. Future extensions of this stage may",
"add a key parameter if and when it becomes a requirement. ",
"In its current state, the stage can be used to replicate the generation of",
"kernel hmac files [1] which are used for integrity verification when booting",
"in FIPS mode.",
"Notes:",
" - Requires hmac calc in the build root (libkcapi-hmaccalc)",
"Links:",
"[1] https://gitlab.com/redhat/centos-stream/rpms/kernel/-/blob/f5b2a5f2ae8040c6072382545d302a4a936cb53c/kernel.spec?page=3#L2370"
],
"schema_2": {
"options": {
"additionalProperties": false,
"required": [
"paths",
"algorithm"
],
"properties": {
"paths": {
"type": "array",
"minItems": 1,
"items": {
"type": "string"
}
},
"algorithm": {
"type": "string",
"enum": [
"sha1",
"sha224",
"sha256",
"sha384",
"sha512"
]
}
}
}
}
}

91
stages/test/test_hmac.py Normal file
View file

@ -0,0 +1,91 @@
import os.path
import re
from unittest import mock
import pytest
from osbuild import testutil
STAGE_NAME = "org.osbuild.hmac"
@pytest.mark.parametrize("test_data,expected_errs", [
# bad
({}, [r"'paths' is a required property", r"'algorithm' is a required property"]),
({"algorithm": "sha512"}, [r"'paths' is a required property"]),
({"paths": ["/somefile"]}, [r"'algorithm' is a required property"]),
({"paths": [], "algorithm": "sha1"}, [r"(\[\] is too short|\[\] should be non-empty)"]),
({"paths": ["/somefiles"], "algorithm": "md5"},
[r"'md5' is not one of \['sha1', 'sha224', 'sha256', 'sha384', 'sha512'\]"]),
# good
(
{
"paths": [
"/greet1.txt",
"/greet2.txt"
],
"algorithm": "sha256"
},
""
),
(
{
"paths": [
"/path/to/some/file",
"/boot/efi/EFI/Linux/vmlinuz-linux"
],
"algorithm": "sha512"
},
""
),
])
def test_schema_validation_hmac(stage_schema, test_data, expected_errs):
test_input = {
"type": STAGE_NAME,
"options": {
}
}
test_input["options"].update(test_data)
res = stage_schema.validate(test_input)
if not expected_errs:
assert res.valid is True, f"err: {[e.as_dict() for e in res.errors]}"
else:
assert res.valid is False
for exp_err in expected_errs:
testutil.assert_jsonschema_error_contains(res, re.compile(exp_err), expected_num_errs=len(expected_errs))
@mock.patch("subprocess.run")
@pytest.mark.parametrize("options", [
({"paths": ["/a/file/path"], "algorithm": "sha512"}),
({"paths": ["/b/file/path"], "algorithm": "sha512"}),
({"paths": ["/a/file/path", "/b/file/path"], "algorithm": "sha256"}),
])
def test_hmac_cmdline(mock_run, tmp_path, stage_module, options):
algorithm = options["algorithm"]
expected_cmds = []
expected_wds = []
hmac_paths = []
for path in options["paths"]:
# create parent directories because the stage opens a file to write the output to it even when mocking sp.run()
basename = os.path.basename(path)
real_path = os.path.join(tmp_path, path.lstrip("/"))
parent = os.path.dirname(real_path)
hmac_paths.append(os.path.join(parent, f".{basename}.hmac"))
os.makedirs(parent, exist_ok=True)
expected_cmds.append([f"{algorithm}hmac", basename])
expected_wds.append(parent)
stage_module.main(tmp_path, options)
for exp, cwd, actual in zip(expected_cmds, expected_wds, mock_run.call_args_list):
assert exp == actual[0][0]
assert cwd == actual[1]["cwd"]
# the file should have been created by the open() call, but should be empty because we mocked sp.run()
for path in hmac_paths:
info = os.stat(path)
assert info.st_size == 0