parent
948bab6f9f
commit
50795e4885
@ -0,0 +1,269 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import functools
|
||||||
|
import sys
|
||||||
|
import textwrap
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import tempfile
|
||||||
|
from typing import Iterator, Tuple
|
||||||
|
|
||||||
|
import plumbum
|
||||||
|
|
||||||
|
import cryptography.exceptions
|
||||||
|
import cryptography.x509
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ec import ECDSA, EllipticCurvePublicKey
|
||||||
|
import rpm
|
||||||
|
|
||||||
|
|
||||||
|
class NoIMASignatureError(Exception):
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class IMASignatureFormatError(Exception):
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def init_arg_parser() -> argparse.ArgumentParser:
|
||||||
|
"""
|
||||||
|
Initializes a command line arguments parser.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Command line arguments parser.
|
||||||
|
"""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog='rpm-ima-inspector',
|
||||||
|
description='Verifies RPM package IMA signatures'
|
||||||
|
)
|
||||||
|
parser.add_argument('-c', '--ima-cert', required=True,
|
||||||
|
help='public IMA certificate DER file path')
|
||||||
|
parser.add_argument('rpm_file', metavar='RPM_FILE', help='RPM file path')
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def init_rpm_ts() -> rpm.TransactionSet:
|
||||||
|
"""
|
||||||
|
Initializes an RPM transaction.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
RPM transaction.
|
||||||
|
"""
|
||||||
|
ts = rpm.TransactionSet()
|
||||||
|
ts.setVSFlags(rpm._RPMVSF_NOSIGNATURES)
|
||||||
|
return ts
|
||||||
|
|
||||||
|
|
||||||
|
def load_pub_key_from_der(cert_path: str) -> EllipticCurvePublicKey:
|
||||||
|
"""
|
||||||
|
Loads a public key from a DER-formatted certificate file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cert_path: Certificate file path.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Public key.
|
||||||
|
"""
|
||||||
|
with open(cert_path, 'rb') as fd:
|
||||||
|
cert = cryptography.x509.load_der_x509_certificate(fd.read())
|
||||||
|
return cert.public_key()
|
||||||
|
|
||||||
|
|
||||||
|
def get_pub_key_id(pub_key: EllipticCurvePublicKey) -> str:
|
||||||
|
"""
|
||||||
|
Extracts a key ID from a public key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pub_key: Public key.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Public key ID.
|
||||||
|
"""
|
||||||
|
key_id = cryptography.x509.SubjectKeyIdentifier.from_public_key(pub_key)
|
||||||
|
return key_id.digest[-4:].hex()
|
||||||
|
|
||||||
|
|
||||||
|
def read_rpm_header(rpm_path: str, ts: rpm.TransactionSet) -> rpm.hdr:
|
||||||
|
with open(rpm_path, 'r') as fd:
|
||||||
|
return ts.hdrFromFdno(fd)
|
||||||
|
|
||||||
|
|
||||||
|
def get_rpm_ima_signatures(
|
||||||
|
rpm_path: str, ts: rpm.TransactionSet
|
||||||
|
) -> Iterator[Tuple[str, str]]:
|
||||||
|
"""
|
||||||
|
Iterates over an RPM package files and their signatures.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rpm_path: RPM file path.
|
||||||
|
ts: RPM transaction set.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Iterator over RPM package files and their signatures.
|
||||||
|
"""
|
||||||
|
with open(rpm_path, 'rb') as fd:
|
||||||
|
hdr = ts.hdrFromFdno(fd)
|
||||||
|
files = hdr[rpm.RPMTAG_FILENAMES]
|
||||||
|
signatures = hdr[rpm.RPMTAG_FILESIGNATURES]
|
||||||
|
if len(files) != len(signatures):
|
||||||
|
raise NoIMASignatureError(f'there are no IMA signatures in {rpm_path}')
|
||||||
|
return zip(files, signatures)
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def unpack_rpm_to_tmp(rpm_path: str):
|
||||||
|
rpm2cpio = plumbum.local['rpm2cpio']
|
||||||
|
cpio = plumbum.local['cpio']
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
with plumbum.local.cwd(tmp_dir):
|
||||||
|
cmd = rpm2cpio[rpm_path] | cpio['-idmv', '--no-absolute-filenames']
|
||||||
|
cmd()
|
||||||
|
yield tmp_dir
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ima_signature(sig_hdr: str) -> Tuple[str, bytes]:
|
||||||
|
"""
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sig_hdr:
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
The constant values are taken from the imaevm.h file.
|
||||||
|
|
||||||
|
See the signature_v2_hdr structure definition in the imaevm.h file
|
||||||
|
for a signature header format description.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Public key ID and a file signature.
|
||||||
|
"""
|
||||||
|
EVM_IMA_XATTR_DIGSIG = 3
|
||||||
|
DIGSIG_VERSION_2 = 2
|
||||||
|
PKEY_HASH_SHA256 = 4
|
||||||
|
byte_sign = bytearray.fromhex(sig_hdr)
|
||||||
|
if byte_sign[0] != EVM_IMA_XATTR_DIGSIG:
|
||||||
|
raise IMASignatureFormatError(f'invalid signature type {byte_sign[0]}')
|
||||||
|
elif byte_sign[1] != DIGSIG_VERSION_2:
|
||||||
|
raise IMASignatureFormatError(f'only V2 format signatures are supported')
|
||||||
|
elif byte_sign[2] != PKEY_HASH_SHA256:
|
||||||
|
raise IMASignatureFormatError(f'only SHA256 digest algorithm is supported')
|
||||||
|
pub_key_id = bytes(byte_sign[3:7]).hex()
|
||||||
|
sig_size = struct.unpack('>H', byte_sign[7:9])[0]
|
||||||
|
signature = bytes(byte_sign[9:sig_size+9])
|
||||||
|
return pub_key_id, signature
|
||||||
|
|
||||||
|
|
||||||
|
class TapProducer:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.i = 0
|
||||||
|
|
||||||
|
def counter(fn):
|
||||||
|
@functools.wraps(fn)
|
||||||
|
def wrap(self, *args, **kwargs):
|
||||||
|
self.i += 1
|
||||||
|
return fn(self, *args, **kwargs)
|
||||||
|
return wrap
|
||||||
|
|
||||||
|
@counter
|
||||||
|
def abort(self, description: str = None, payload: dict = None):
|
||||||
|
self._render(False, description, payload)
|
||||||
|
sys.stdout.write(f'1..{self.i} # fatal error\n')
|
||||||
|
|
||||||
|
@counter
|
||||||
|
def failed(self, description: str = None, payload: dict = None):
|
||||||
|
self._render(False, description, payload)
|
||||||
|
|
||||||
|
def finalize(self):
|
||||||
|
sys.stdout.write(f'1..{self.i}\n')
|
||||||
|
|
||||||
|
@counter
|
||||||
|
def passed(self, description: str = None, payload: dict = None):
|
||||||
|
"""
|
||||||
|
Reports a passed test.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
description: Test description.
|
||||||
|
payload: Extra diagnostics payload to be serialized as YAML.
|
||||||
|
"""
|
||||||
|
self._render(True, description, payload)
|
||||||
|
|
||||||
|
@counter
|
||||||
|
def skipped(self, description: str = None, payload: dict = None,
|
||||||
|
reason: str = None):
|
||||||
|
self._render(True, description, payload, skip=True, reason=reason)
|
||||||
|
|
||||||
|
def _render(self, success: bool, description: str = None,
|
||||||
|
payload: dict = None, skip: bool = False, reason: str = None):
|
||||||
|
status = 'ok' if success else 'not ok'
|
||||||
|
sys.stdout.write(f'{status} {self.i}')
|
||||||
|
if description:
|
||||||
|
sys.stdout.write(f' - {description}')
|
||||||
|
if skip:
|
||||||
|
sys.stdout.write(' # SKIP')
|
||||||
|
if reason is not None:
|
||||||
|
sys.stdout.write(f' {reason}')
|
||||||
|
# TODO: add todo directive support
|
||||||
|
sys.stdout.write('\n')
|
||||||
|
if payload:
|
||||||
|
yaml_str = yaml.dump(payload, explicit_start=True,
|
||||||
|
explicit_end=True, indent=2)
|
||||||
|
yaml_str = textwrap.indent(yaml_str, ' ')
|
||||||
|
sys.stdout.write(yaml_str)
|
||||||
|
|
||||||
|
counter = staticmethod(counter)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
arg_parser = init_arg_parser()
|
||||||
|
args = arg_parser.parse_args()
|
||||||
|
rpm_file = args.rpm_file
|
||||||
|
tap = TapProducer()
|
||||||
|
#
|
||||||
|
step = 'load public IMA certificate'
|
||||||
|
try:
|
||||||
|
pub_key = load_pub_key_from_der(args.ima_cert)
|
||||||
|
pub_key_id = get_pub_key_id(pub_key)
|
||||||
|
tap.passed(f'{step} with {pub_key_id} key ID')
|
||||||
|
except Exception as e:
|
||||||
|
tap.abort(step, {'message': str(e), 'file': args.ima_cert})
|
||||||
|
sys.exit(1)
|
||||||
|
#
|
||||||
|
rpm_ts = init_rpm_ts()
|
||||||
|
step = 'read RPM IMA signatures'
|
||||||
|
try:
|
||||||
|
ima_sigs = get_rpm_ima_signatures(rpm_file, rpm_ts)
|
||||||
|
tap.passed(step)
|
||||||
|
except NoIMASignatureError as e:
|
||||||
|
tap.abort(step, {'message': str(e), 'file': rpm_file})
|
||||||
|
sys.exit(1)
|
||||||
|
#
|
||||||
|
failed = False
|
||||||
|
with unpack_rpm_to_tmp(rpm_file) as rpm_dir:
|
||||||
|
for rel_path, sig_hdr in ima_sigs:
|
||||||
|
file_path = os.path.join(rpm_dir, rel_path)
|
||||||
|
if not os.path.isfile(file_path) or os.path.islink(file_path):
|
||||||
|
tap.skipped(f'{rel_path} is not a regular file')
|
||||||
|
continue
|
||||||
|
key_id, sig = parse_ima_signature(sig_hdr)
|
||||||
|
with open(file_path, 'rb') as fd:
|
||||||
|
try:
|
||||||
|
pub_key.verify(sig, fd.read(), ECDSA(hashes.SHA256()))
|
||||||
|
assert pub_key_id == key_id
|
||||||
|
tap.passed(f'{rel_path} is signed with {key_id}')
|
||||||
|
except cryptography.exceptions.InvalidSignature:
|
||||||
|
tap.failed(f'{rel_path} signature is not valid')
|
||||||
|
failed = True
|
||||||
|
tap.finalize()
|
||||||
|
if failed:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
Loading…
Reference in new issue