diff --git a/stages/org.osbuild.hmac b/stages/org.osbuild.hmac new file mode 100755 index 00000000..1e45f89c --- /dev/null +++ b/stages/org.osbuild.hmac @@ -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) diff --git a/stages/org.osbuild.hmac.meta.json b/stages/org.osbuild.hmac.meta.json new file mode 100644 index 00000000..20f309d8 --- /dev/null +++ b/stages/org.osbuild.hmac.meta.json @@ -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 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" + ] + } + } + } + } +} diff --git a/stages/test/test_hmac.py b/stages/test/test_hmac.py new file mode 100644 index 00000000..02203df3 --- /dev/null +++ b/stages/test/test_hmac.py @@ -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