diff --git a/.gitignore b/.gitignore index 9de8aa5..26f5537 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ /keylime-selinux-38.1.0.tar.gz /v7.12.1.tar.gz /keylime-selinux-42.1.2.tar.gz +/v7.14.2.tar.gz +/keylime-selinux-44.1.0.tar.gz +/v7.14.3.tar.gz diff --git a/0001-Make-keylime-compatible-with-python-3.9.patch b/0001-Make-keylime-compatible-with-python-3.9.patch index 7239692..d0b9641 100644 --- a/0001-Make-keylime-compatible-with-python-3.9.patch +++ b/0001-Make-keylime-compatible-with-python-3.9.patch @@ -1,27 +1,41 @@ -From f7c32aec9c44a176124d982d942391ed3d50e846 Mon Sep 17 00:00:00 2001 +From 3e7d6ba40326aff631a19c35b78f2ba3543be140 Mon Sep 17 00:00:00 2001 From: Sergio Correia -Date: Tue, 3 Jun 2025 21:23:09 +0100 -Subject: [PATCH 1/6] Make keylime compatible with python 3.9 +Date: Mon, 6 Jul 2026 08:21:01 +0000 +Subject: [PATCH 1/4] Make keylime compatible with python 3.9 Signed-off-by: Sergio Correia --- - keylime/ima/types.py | 33 ++++---- - keylime/models/base/basic_model.py | 4 +- - keylime/models/base/basic_model_meta.py | 4 +- - keylime/models/base/field.py | 4 +- - keylime/models/base/persistable_model.py | 4 +- - keylime/models/base/type.py | 4 +- - keylime/models/base/types/base64_bytes.py | 4 +- - keylime/models/base/types/certificate.py | 92 +++++++++++---------- - keylime/models/base/types/dictionary.py | 4 +- - keylime/models/base/types/one_of.py | 6 +- - keylime/models/registrar/registrar_agent.py | 31 +++---- - keylime/policy/create_runtime_policy.py | 2 +- - keylime/registrar_client.py | 8 +- - keylime/web/base/action_handler.py | 7 +- - keylime/web/base/controller.py | 78 ++++++++--------- - tox.ini | 10 +++ - 16 files changed, 154 insertions(+), 141 deletions(-) + keylime/ima/types.py | 33 +++--- + keylime/models/base/associations.py | 4 +- + keylime/models/base/basic_model.py | 2 +- + keylime/models/base/basic_model_meta.py | 4 +- + keylime/models/base/db.py | 4 +- + keylime/models/base/field.py | 6 +- + keylime/models/base/persistable_model.py | 4 +- + keylime/models/base/persistable_model_meta.py | 4 +- + keylime/models/base/record_set.py | 6 +- + keylime/models/base/type.py | 4 +- + keylime/models/base/types/binary.py | 8 +- + keylime/models/base/types/certificate.py | 106 +++++++++--------- + keylime/models/base/types/dictionary.py | 4 +- + keylime/models/base/types/list.py | 4 +- + keylime/models/base/types/one_of.py | 8 +- + keylime/models/base/types/timestamp.py | 4 +- + keylime/models/registrar/registrar_agent.py | 30 ++--- + keylime/registrar_client.py | 7 +- + keylime/verification/base/engine_driver.py | 10 +- + .../verification/base/verification_engine.py | 10 +- + keylime/verification/tpm_engine.py | 6 +- + keylime/web/base/action_handler.py | 2 +- + keylime/web/base/api_messages/api_error.py | 60 +++++----- + keylime/web/base/api_messages/api_links.py | 10 +- + .../web/base/api_messages/api_message_body.py | 24 ++-- + keylime/web/base/api_messages/api_resource.py | 42 +++---- + keylime/web/base/controller.py | 82 +++++++------- + keylime/web/base/route.py | 4 +- + keylime/web/base/server.py | 50 +++++---- + test/test_authorization_integration.py | 9 +- + 30 files changed, 277 insertions(+), 274 deletions(-) diff --git a/keylime/ima/types.py b/keylime/ima/types.py index 99f0aa7..a0fffdf 100644 @@ -89,23 +103,43 @@ index 99f0aa7..a0fffdf 100644 + "verification-keys": str, }, ) +diff --git a/keylime/models/base/associations.py b/keylime/models/base/associations.py +index 575171d..784f1c5 100644 +--- a/keylime/models/base/associations.py ++++ b/keylime/models/base/associations.py +@@ -171,7 +171,7 @@ class EmbedsOneAssociation(EmbeddedAssociation): + else: + self._set_one(parent_record, other_record) + +- def to_column(self, name: str | None = None) -> Optional[Column]: ++ def to_column(self, name: Union[str, None] = None) -> Optional[Column]: + if not name: + name = self.name + +@@ -217,7 +217,7 @@ class EmbedsManyAssociation(EmbeddedAssociation): + ) -> Union["AssociatedRecordSet", "EmbedsManyAssociation", None]: + return self._get_many(parent_record) + +- def to_column(self, name: str | None = None) -> Optional[Column]: ++ def to_column(self, name: Union[str, None] = None) -> Optional[Column]: + if not name: + name = self.name + diff --git a/keylime/models/base/basic_model.py b/keylime/models/base/basic_model.py -index 68a126e..6f5de83 100644 +index 83876c6..a30a978 100644 --- a/keylime/models/base/basic_model.py +++ b/keylime/models/base/basic_model.py -@@ -407,7 +407,9 @@ class BasicModel(ABC, metaclass=BasicModelMeta): +@@ -439,7 +439,7 @@ class BasicModel(ABC, metaclass=BasicModelMeta): if max and length > max: self._add_error(field, msg or f"should be at most {length} {element_type}(s)") - def validate_number(self, field: str, *expressions: tuple[str, int | float], msg: Optional[str] = None) -> None: -+ def validate_number( -+ self, field: str, *expressions: tuple[str, Union[int, float]], msg: Optional[str] = None -+ ) -> None: ++ def validate_number(self, field: str, *expressions: tuple[str, Union[int, float]], msg: Optional[str] = None) -> None: value = self.values.get(field) if not value: diff --git a/keylime/models/base/basic_model_meta.py b/keylime/models/base/basic_model_meta.py -index 353e004..84617d4 100644 +index 42d56ab..c2bdbba 100644 --- a/keylime/models/base/basic_model_meta.py +++ b/keylime/models/base/basic_model_meta.py @@ -1,6 +1,6 @@ @@ -116,7 +150,7 @@ index 353e004..84617d4 100644 from sqlalchemy.types import TypeEngine -@@ -40,7 +40,7 @@ class BasicModelMeta(ABCMeta): +@@ -46,7 +46,7 @@ class BasicModelMeta(ABCMeta): # pylint: disable=bad-staticmethod-argument, no-value-for-parameter, using-constant-test @@ -125,8 +159,30 @@ index 353e004..84617d4 100644 @classmethod def _is_model_class(mcs, cls: type) -> bool: # type: ignore[reportSelfClassParameterName] +diff --git a/keylime/models/base/db.py b/keylime/models/base/db.py +index bb218cf..57dc438 100644 +--- a/keylime/models/base/db.py ++++ b/keylime/models/base/db.py +@@ -2,7 +2,7 @@ import os + from configparser import NoOptionError + from contextlib import contextmanager + from sqlite3 import Connection as SQLite3Connection +-from typing import Any, Dict, Iterator, Optional, cast ++from typing import Any, Dict, Iterator, Optional, cast, Union + + from sqlalchemy import create_engine, event + from sqlalchemy.engine import Engine +@@ -164,7 +164,7 @@ class DBManager: + self._scoped_session.remove() + + @contextmanager +- def session_context(self, session: Session | None = None) -> Iterator[Session]: ++ def session_context(self, session: Union[Session, None] = None) -> Iterator[Session]: + if session: + yield session + return diff --git a/keylime/models/base/field.py b/keylime/models/base/field.py -index 7fb3dcb..d1e3bc3 100644 +index f53ec8d..dd80fc0 100644 --- a/keylime/models/base/field.py +++ b/keylime/models/base/field.py @@ -1,6 +1,6 @@ @@ -135,9 +191,9 @@ index 7fb3dcb..d1e3bc3 100644 -from typing import TYPE_CHECKING, Any, Optional, TypeAlias, Union +from typing import TYPE_CHECKING, Any, Optional, Union + from sqlalchemy import Column, or_ from sqlalchemy.types import TypeEngine - -@@ -23,7 +23,7 @@ class ModelField: +@@ -26,7 +26,7 @@ class ModelField: [2] https://docs.python.org/3/library/functions.html#property """ @@ -146,25 +202,85 @@ index 7fb3dcb..d1e3bc3 100644 FIELD_NAME_REGEX = re.compile(r"^[A-Za-z_]+[A-Za-z0-9_]*$") +@@ -112,7 +112,7 @@ class ModelField: + sa_field = getattr(self.parent.db_mapping, self.name) # type: ignore[attr-defined] + return sa_field.__ge__(other) + +- def to_column(self, name: str | None = None) -> Optional[Column]: ++ def to_column(self, name: Union[str, None] = None) -> Optional[Column]: + if not self.persist: + return None + diff --git a/keylime/models/base/persistable_model.py b/keylime/models/base/persistable_model.py -index 18f7d0d..015d661 100644 +index 4aa596e..006d406 100644 --- a/keylime/models/base/persistable_model.py +++ b/keylime/models/base/persistable_model.py @@ -1,4 +1,4 @@ --from typing import Any, Mapping, Optional, Sequence -+from typing import Any, Mapping, Optional, Sequence, Union +-from typing import Any, Optional, Sequence, TypeVar ++from typing import Any, Optional, Sequence, TypeVar, Union - from keylime.models.base.basic_model import BasicModel - from keylime.models.base.db import db_manager -@@ -165,7 +165,7 @@ class PersistableModel(BasicModel, metaclass=PersistableModelMeta): - else: - return None + from sqlalchemy import asc, desc, or_ + from sqlalchemy.sql.expression import ClauseElement +@@ -240,7 +240,7 @@ class PersistableModel(BasicModel, metaclass=PersistableModelMeta): + cls._query(session, args, kwargs).delete() -- def __init__(self, data: Optional[dict | object] = None, process_associations: bool = True) -> None: -+ def __init__(self, data: Optional[Union[dict, object]] = None, process_associations: bool = True) -> None: + def __init__( +- self, data: Optional[dict | object] = None, process_associations: bool = True, memo: Optional[list] = None ++ self, data: Optional[Union[dict, object]] = None, process_associations: bool = True, memo: Optional[list] = None + ) -> None: if isinstance(data, type(self).db_mapping): super().__init__({}, process_associations) - self._init_from_mapping(data, process_associations) +diff --git a/keylime/models/base/persistable_model_meta.py b/keylime/models/base/persistable_model_meta.py +index fd02549..6403493 100644 +--- a/keylime/models/base/persistable_model_meta.py ++++ b/keylime/models/base/persistable_model_meta.py +@@ -1,5 +1,5 @@ + import re +-from typing import Any, Optional, Pattern, TypeAlias ++from typing import Any, Optional, Pattern + + from sqlalchemy import ForeignKeyConstraint, Integer, Table, Text + from sqlalchemy.dialects.mysql import LONGTEXT +@@ -53,7 +53,7 @@ class PersistableModelMeta(BasicModelMeta): + + # pylint: disable=using-constant-test, no-value-for-parameter, unused-private-member, unsupported-membership-test, no-else-return + +- DeclaredFieldType: TypeAlias = BasicModelMeta.DeclaredFieldType # type: ignore[reportIncompatibleVariableOverride, reportAssignmentType] ++ DeclaredFieldType = BasicModelMeta.DeclaredFieldType # type: ignore[reportIncompatibleVariableOverride, reportAssignmentType] + + TABLE_NAME_REGEX: Pattern = re.compile(r"^[A-Za-z_]+[A-Za-z0-9_]*$") + +diff --git a/keylime/models/base/record_set.py b/keylime/models/base/record_set.py +index 55d40de..192db67 100644 +--- a/keylime/models/base/record_set.py ++++ b/keylime/models/base/record_set.py +@@ -37,7 +37,7 @@ class RecordSet(set["BasicModel"]): + def __iter__(self) -> Iterator["BasicModel"]: + return iter(self._order) + +- def __getitem__(self, arg: int | slice) -> "BasicModel | list[BasicModel]": ++ def __getitem__(self, arg: Union[int, slice]) -> "BasicModel | list[BasicModel]": + return self._order[arg] + + def __copy__(self) -> "RecordSet": +@@ -175,7 +175,7 @@ class RecordSetView: # pylint: disable=protected-access # Intentional access t + return new_view + + def _filter_func( +- self, func: Callable[["BasicModel"], bool] | None, criteria: dict[str, Any] ++ self, func: Union[Callable[["BasicModel"], bool], None], criteria: dict[str, Any] + ) -> Callable[["BasicModel"], bool]: + if func: + return func +@@ -191,7 +191,7 @@ class RecordSetView: # pylint: disable=protected-access # Intentional access t + + return default_filter_func + +- def filter(self, func: Callable[["BasicModel"], bool] | None = None, **criteria: Any) -> "RecordSetView": ++ def filter(self, func: Union[Callable[["BasicModel"], bool], None] = None, **criteria: Any) -> "RecordSetView": + if not self._content: + return self + diff --git a/keylime/models/base/type.py b/keylime/models/base/type.py index 2520f72..e4d924c 100644 --- a/keylime/models/base/type.py @@ -187,29 +303,42 @@ index 2520f72..e4d924c 100644 def __init__(self, type_engine: DeclaredTypeEngine) -> None: if isclass(type_engine) and issubclass(type_engine, TypeEngine): -diff --git a/keylime/models/base/types/base64_bytes.py b/keylime/models/base/types/base64_bytes.py -index b9b4b13..a1eeced 100644 ---- a/keylime/models/base/types/base64_bytes.py -+++ b/keylime/models/base/types/base64_bytes.py -@@ -1,6 +1,6 @@ +diff --git a/keylime/models/base/types/binary.py b/keylime/models/base/types/binary.py +index 5dde1d2..200eaad 100644 +--- a/keylime/models/base/types/binary.py ++++ b/keylime/models/base/types/binary.py +@@ -1,7 +1,7 @@ import base64 import binascii --from typing import Optional, TypeAlias, Union -+from typing import Optional, Union + from inspect import isclass +-from typing import Any, Optional, TypeAlias, Union ++from typing import Any, Optional, Union - from sqlalchemy.types import Text + from sqlalchemy.types import LargeBinary, String -@@ -62,7 +62,7 @@ class Base64Bytes(ModelType): - b64_str = Base64Bytes().cast("MIIE...") +@@ -82,9 +82,9 @@ class Binary(ModelType): + quote = Binary().cast("0x03ff...") """ - IncomingValue: TypeAlias = Union[bytes, str, None] + IncomingValue = Union[bytes, str, None] - def __init__(self) -> None: - super().__init__(Text) +- def __init__(self, persist_as: type[LargeBinary] | type[String] | LargeBinary | String = LargeBinary) -> None: ++ def __init__(self, persist_as: Union[type[LargeBinary], type[String], LargeBinary, String] = LargeBinary) -> None: + if not isinstance(persist_as, (LargeBinary, String)) and not issubclass(persist_as, (LargeBinary, String)): + raise TypeError("field of type 'Binary' must have a persist_as value of type 'LargeBinary' or 'String'") + +@@ -125,7 +125,7 @@ class Binary(ModelType): + def generate_error_msg(self, _value: IncomingValue) -> str: + return "must be valid binary" + +- def _dump(self, value: IncomingValue) -> Optional[str | bytes]: ++ def _dump(self, value: IncomingValue) -> Optional[Union[str, bytes]]: + # pylint: disable=no-else-return + + value = self.cast(value) diff --git a/keylime/models/base/types/certificate.py b/keylime/models/base/types/certificate.py -index 2c27603..0f03169 100644 +index bf2d62f..9113a60 100644 --- a/keylime/models/base/types/certificate.py +++ b/keylime/models/base/types/certificate.py @@ -1,7 +1,7 @@ @@ -221,40 +350,49 @@ index 2c27603..0f03169 100644 import cryptography.x509 from cryptography.hazmat.primitives.serialization import Encoding -@@ -78,7 +78,7 @@ class Certificate(ModelType): +@@ -79,7 +79,7 @@ class Certificate(ModelType): cert = Certificate().cast("-----BEGIN CERTIFICATE-----\nMIIE...") """ -- IncomingValue: TypeAlias = Union[cryptography.x509.Certificate, bytes, str, None] -+ IncomingValue = Union[cryptography.x509.Certificate, bytes, str, None] +- IncomingValue: TypeAlias = Union[cryptography.x509.Certificate, CertificateWrapper, bytes, str, None] ++ IncomingValue = Union[cryptography.x509.Certificate, CertificateWrapper, bytes, str, None] def __init__(self) -> None: super().__init__(Text) -@@ -195,18 +195,19 @@ class Certificate(ModelType): +@@ -213,23 +213,23 @@ class Certificate(ModelType): """ try: - match self.infer_encoding(value): +- case "wrapped": +- # For CertificateWrapper objects, check if they have original bytes (indicating re-encoding was needed) +- return not value.has_original_bytes # type: ignore[union-attr] - case "decoded": - return None +- case "disabled": +- return True - case "der": - cryptography.x509.load_der_x509_certificate(value) # type: ignore[reportArgumentType, arg-type] - case "pem": -- cryptography.x509.load_pem_x509_certificate(value) # type: ignore[reportArgumentType, arg-type] +- cryptography.x509.load_pem_x509_certificate(value.encode("utf-8")) # type: ignore[reportArgumentType, arg-type, union-attr] - case "base64": - der_value = base64.b64decode(value, validate=True) # type: ignore[reportArgumentType, arg-type] - cryptography.x509.load_der_x509_certificate(der_value) - case _: - raise Exception -+ encoding_inf = self.infer_encoding(value) -+ if encoding_inf == "decoded": ++ __match_val = self.infer_encoding(value) ++ if __match_val == "wrapped": ++ # For CertificateWrapper objects, check if they have original bytes (indicating re-encoding was needed) ++ return not value.has_original_bytes # type: ignore[union-attr] ++ elif __match_val == "decoded": + return None -+ -+ if encoding_inf == "der": ++ elif __match_val == "disabled": ++ return True ++ elif __match_val == "der": + cryptography.x509.load_der_x509_certificate(value) # type: ignore[reportArgumentType, arg-type] -+ elif encoding_inf == "pem": -+ cryptography.x509.load_pem_x509_certificate(value) # type: ignore[reportArgumentType, arg-type] -+ elif encoding_inf == "base64": ++ elif __match_val == "pem": ++ cryptography.x509.load_pem_x509_certificate(value.encode("utf-8")) # type: ignore[reportArgumentType, arg-type, union-attr] ++ elif __match_val == "base64": + der_value = base64.b64decode(value, validate=True) # type: ignore[reportArgumentType, arg-type] + cryptography.x509.load_der_x509_certificate(der_value) + else: @@ -262,13 +400,16 @@ index 2c27603..0f03169 100644 except Exception: return False -@@ -227,37 +228,38 @@ class Certificate(ModelType): +@@ -250,40 +250,40 @@ class Certificate(ModelType): if not value: return None - match self.infer_encoding(value): +- case "wrapped": +- return value # type: ignore[return-value] - case "decoded": -- return value # type: ignore[reportReturnType, return-value] +- # Wrap raw cryptography certificate without original bytes +- return wrap_certificate(value, None) # type: ignore[arg-type] - case "der": - try: - return self._load_der_cert(value) # type: ignore[reportArgumentType, arg-type] @@ -297,18 +438,20 @@ index 2c27603..0f03169 100644 - f"value cast to certificate is of type '{value.__class__.__name__}' but should be one of 'str', " - f"'bytes' or 'cryptography.x509.Certificate': '{str(value)}'" - ) -+ encoding_inf = self.infer_encoding(value) -+ if encoding_inf == "decoded": -+ return value # type: ignore[reportReturnType, return-value] -+ -+ if encoding_inf == "der": ++ __match_val_2 = self.infer_encoding(value) ++ if __match_val_2 == "wrapped": ++ return value # type: ignore[return-value] ++ elif __match_val_2 == "decoded": ++ # Wrap raw cryptography certificate without original bytes ++ return wrap_certificate(value, None) # type: ignore[arg-type] ++ elif __match_val_2 == "der": + try: + return self._load_der_cert(value) # type: ignore[reportArgumentType, arg-type] + except PyAsn1Error as err: + raise ValueError( + f"value cast to certificate appears DER encoded but cannot be deserialized as such: {value!r}" + ) from err -+ elif encoding_inf == "pem": ++ elif __match_val_2 == "pem": + try: + return self._load_pem_cert(value) # type: ignore[reportArgumentType, arg-type] + except PyAsn1Error as err: @@ -316,7 +459,7 @@ index 2c27603..0f03169 100644 + f"value cast to certificate appears PEM encoded but cannot be deserialized as such: " + f"'{str(value)}'" + ) from err -+ elif encoding_inf == "base64": ++ elif __match_val_2 == "base64": + try: + return self._load_der_cert(base64.b64decode(value, validate=True)) # type: ignore[reportArgumentType, arg-type] + except (binascii.Error, PyAsn1Error) as err: @@ -333,7 +476,7 @@ index 2c27603..0f03169 100644 def generate_error_msg(self, _value: IncomingValue) -> str: return "must be a valid X.509 certificate in PEM format or otherwise encoded using Base64" diff --git a/keylime/models/base/types/dictionary.py b/keylime/models/base/types/dictionary.py -index 7d9e811..d9ffec3 100644 +index dda8117..2d763e6 100644 --- a/keylime/models/base/types/dictionary.py +++ b/keylime/models/base/types/dictionary.py @@ -1,5 +1,5 @@ @@ -350,10 +493,30 @@ index 7d9e811..d9ffec3 100644 - IncomingValue: TypeAlias = Union[dict, str, None] + IncomingValue = Union[dict, str, None] + def __init__(self) -> None: + super().__init__(Text) +diff --git a/keylime/models/base/types/list.py b/keylime/models/base/types/list.py +index 49da257..be9963f 100644 +--- a/keylime/models/base/types/list.py ++++ b/keylime/models/base/types/list.py +@@ -1,5 +1,5 @@ + import json +-from typing import Optional, TypeAlias, Union ++from typing import Optional, Union + + from sqlalchemy.types import Text + +@@ -50,7 +50,7 @@ class List(ModelType): + names = Dictionary().cast('["Jane", "John"]') + """ + +- IncomingValue: TypeAlias = Union[list, str, None] ++ IncomingValue = Union[list, str, None] + def __init__(self) -> None: super().__init__(Text) diff --git a/keylime/models/base/types/one_of.py b/keylime/models/base/types/one_of.py -index 479d417..faf097d 100644 +index 56f8d67..c21b00f 100644 --- a/keylime/models/base/types/one_of.py +++ b/keylime/models/base/types/one_of.py @@ -1,6 +1,6 @@ @@ -375,13 +538,40 @@ index 479d417..faf097d 100644 def __init__(self, *args: Declaration) -> None: # pylint: disable=super-init-not-called +@@ -246,5 +246,5 @@ class OneOf(ModelType): + return self._permitted.copy() + + @property +- def native_type(self) -> type | None: # type: ignore[override] ++ def native_type(self) -> Union[type, None]: # type: ignore[override] + return None +diff --git a/keylime/models/base/types/timestamp.py b/keylime/models/base/types/timestamp.py +index 22c1fcb..60258cb 100644 +--- a/keylime/models/base/types/timestamp.py ++++ b/keylime/models/base/types/timestamp.py +@@ -1,5 +1,5 @@ + from datetime import datetime, timezone +-from typing import Optional, TypeAlias, Union ++from typing import Optional, Union + + from sqlalchemy.types import String + +@@ -7,7 +7,7 @@ from keylime.models.base.type import ModelType + + + class Timestamp(ModelType): +- IncomingValue: TypeAlias = Union[datetime, str, int, float, None] ++ IncomingValue = Union[datetime, str, int, float, None] + + @staticmethod + def now() -> datetime: diff --git a/keylime/models/registrar/registrar_agent.py b/keylime/models/registrar/registrar_agent.py -index 560c188..b232049 100644 +index 9894f45..0fb2eb7 100644 --- a/keylime/models/registrar/registrar_agent.py +++ b/keylime/models/registrar/registrar_agent.py -@@ -153,21 +153,22 @@ class RegistrarAgent(PersistableModel): - names = ", ".join(non_compliant_certs) - names = " and".join(names.rsplit(",", 1)) +@@ -262,21 +262,21 @@ class RegistrarAgent(PersistableModel): + if not non_compliant_certs: + return - match config.get("registrar", "malformed_cert_action"): - case "ignore": @@ -390,51 +580,37 @@ index 560c188..b232049 100644 - logger.error( - "Certificate(s) %s may not conform to strict ASN.1 DER encoding rules and were rejected due to " - "config ('malformed_cert_action = reject')", -- names, +- keylime_logging.list_items(non_compliant_certs), - ) - case _: - logger.warning( - "Certificate(s) %s may not conform to strict ASN.1 DER encoding rules and were re-encoded before " - "parsing by python-cryptography", -- names, +- keylime_logging.list_items(non_compliant_certs), - ) -+ cfg = config.get("registrar", "malformed_cert_action") -+ if cfg == "ignore": ++ __match_val = config.get("registrar", "malformed_cert_action") ++ if __match_val == "ignore": + return -+ -+ if cfg == "reject": ++ elif __match_val == "reject": + logger.error( + "Certificate(s) %s may not conform to strict ASN.1 DER encoding rules and were rejected due to " + "config ('malformed_cert_action = reject')", -+ names, ++ keylime_logging.list_items(non_compliant_certs), + ) + else: + logger.warning( + "Certificate(s) %s may not conform to strict ASN.1 DER encoding rules and were re-encoded before " + "parsing by python-cryptography", -+ names, ++ keylime_logging.list_items(non_compliant_certs), + ) def _bind_ak_to_iak(self, iak_attest, iak_sign): # The ak-iak binding should only be verified when either aik_tpm or iak_tpm is changed -diff --git a/keylime/policy/create_runtime_policy.py b/keylime/policy/create_runtime_policy.py -index 6a412c4..8e1c687 100644 ---- a/keylime/policy/create_runtime_policy.py -+++ b/keylime/policy/create_runtime_policy.py -@@ -972,7 +972,7 @@ def create_runtime_policy(args: argparse.Namespace) -> Optional[RuntimePolicyTyp - ) - abort = True - else: -- if a not in algorithms.Hash: -+ if a not in set(algorithms.Hash): - if a == SHA256_OR_SM3: - algo = a - else: diff --git a/keylime/registrar_client.py b/keylime/registrar_client.py -index 705ff12..97fbc2a 100644 +index 8494e1d..a1ad80e 100644 --- a/keylime/registrar_client.py +++ b/keylime/registrar_client.py -@@ -13,12 +13,6 @@ if sys.version_info >= (3, 8): +@@ -13,11 +13,6 @@ if sys.version_info >= (3, 8): else: from typing_extensions import TypedDict @@ -443,11 +619,10 @@ index 705ff12..97fbc2a 100644 -else: - from typing_extensions import NotRequired - -- + class RegistrarData(TypedDict): ip: Optional[str] - port: Optional[str] -@@ -27,7 +21,7 @@ class RegistrarData(TypedDict): +@@ -27,7 +22,7 @@ class RegistrarData(TypedDict): aik_tpm: str ek_tpm: str ekcert: Optional[str] @@ -456,43 +631,483 @@ index 705ff12..97fbc2a 100644 logger = keylime_logging.init_logging("registrar_client") +diff --git a/keylime/verification/base/engine_driver.py b/keylime/verification/base/engine_driver.py +index 72a570d..406fc1a 100644 +--- a/keylime/verification/base/engine_driver.py ++++ b/keylime/verification/base/engine_driver.py +@@ -1,7 +1,7 @@ + # pyright: reportAttributeAccessIssue=false + # Uses ORM models with dynamically-created attributes from metaclasses + import re +-from typing import TYPE_CHECKING, Any, overload ++from typing import TYPE_CHECKING, Any, overload, Union + + from keylime.models.base import db_manager + from keylime.verification.base.verification_engine import VerificationEngine +@@ -26,7 +26,7 @@ class EngineDriver: + ... + + @classmethod +- def register_parameter_mutator(cls, engine: VerificationEngineMeta, evidence_types: str | tuple[str, ...]) -> None: ++ def register_parameter_mutator(cls, engine: VerificationEngineMeta, evidence_types: Union[str, tuple[str, ...]]) -> None: + if not issubclass(engine, VerificationEngine): + raise TypeError("only a subclass of VerificationEngine can be registered as a parameter mutator") + +@@ -56,7 +56,7 @@ class EngineDriver: + ... + + @classmethod +- def register_evidence_evaluator(cls, engine: VerificationEngineMeta, evidence_types: str | tuple[str, ...]) -> None: ++ def register_evidence_evaluator(cls, engine: VerificationEngineMeta, evidence_types: Union[str, tuple[str, ...]]) -> None: + if not issubclass(engine, VerificationEngine): + raise TypeError("only a subclass of VerificationEngine can be registered as a evidence evaluator") + +@@ -76,14 +76,14 @@ class EngineDriver: + cls._evidence_evaluators[evidence_type].append(engine) + + @classmethod +- def get_parameter_mutators(cls, evidence_type: str) -> list[type[VerificationEngine]] | None: ++ def get_parameter_mutators(cls, evidence_type: str) -> Union[list[type[VerificationEngine]], None]: + if not isinstance(evidence_type, str): + raise TypeError("evidence type must be given as a string") + + return cls._parameter_mutators.get(evidence_type) + + @classmethod +- def get_evidence_evaluators(cls, evidence_type: str) -> list[type[VerificationEngine]] | None: ++ def get_evidence_evaluators(cls, evidence_type: str) -> Union[list[type[VerificationEngine]], None]: + if not isinstance(evidence_type, str): + raise TypeError("evidence type must be given as a string") + +diff --git a/keylime/verification/base/verification_engine.py b/keylime/verification/base/verification_engine.py +index 1e81944..c763e9a 100644 +--- a/keylime/verification/base/verification_engine.py ++++ b/keylime/verification/base/verification_engine.py +@@ -1,7 +1,7 @@ + # pyright: reportAttributeAccessIssue=false + # Uses ORM models with dynamically-created attributes from metaclasses + from abc import ABC, abstractmethod +-from typing import TYPE_CHECKING, Any ++from typing import TYPE_CHECKING, Any, Union + + from keylime.verification.base.verification_engine_meta import VerificationEngineMeta + +@@ -39,19 +39,19 @@ class VerificationEngine(ABC, metaclass=VerificationEngineMeta): + return self.attestation.stage # type: ignore[no-any-return] + + @property +- def evaluation(self) -> str | None: ++ def evaluation(self) -> Union[str, None]: + return self.attestation.evaluation # type: ignore[no-any-return] + + @evaluation.setter +- def evaluation(self, evaluation: str | None) -> None: ++ def evaluation(self, evaluation: Union[str, None]) -> None: + self.attestation.evaluation = evaluation + + @property +- def failure_reason(self) -> str | None: ++ def failure_reason(self) -> Union[str, None]: + return self.attestation.failure_reason # type: ignore[no-any-return, attr-defined] + + @failure_reason.setter +- def failure_reason(self, failure_reason: str | None) -> None: ++ def failure_reason(self, failure_reason: Union[str, None]) -> None: + self.attestation.failure_reason = failure_reason # type: ignore[attr-defined] + + @property +diff --git a/keylime/verification/tpm_engine.py b/keylime/verification/tpm_engine.py +index 621ffc2..3b48f59 100644 +--- a/keylime/verification/tpm_engine.py ++++ b/keylime/verification/tpm_engine.py +@@ -3,7 +3,7 @@ + import math + import time + from datetime import timedelta +-from typing import TYPE_CHECKING, Any ++from typing import TYPE_CHECKING, Any, Union + + from keylime import agent_util, config, keylime_logging, push_agent_monitor + from keylime.agentstates import AgentAttestState, TPMClockInfo +@@ -32,7 +32,7 @@ class TPMEngine(VerificationEngine): + + def __init__(self, attestation: "Attestation") -> None: + super().__init__(attestation) +- self._attest_state: AgentAttestState | None = None ++ self._attest_state: Union[AgentAttestState, None] = None + + def _validate_system_info(self) -> None: + if not self.expects_ima_log: +@@ -985,7 +985,7 @@ class TPMEngine(VerificationEngine): + return TPMEngine(self.previous_attestation)._select_ima_log_item() # type: ignore[arg-type] + + @property +- def attest_state(self) -> AgentAttestState | None: ++ def attest_state(self) -> Union[AgentAttestState, None]: + """The most current verification state of the attested system as understood by the verifier, returned as an + AgentAttestState object. This object contains the parameters which are used as input to Keylime's low-level + verification functions in ``keylime.tpm``. During verification, these functions update the AgentAttestState diff --git a/keylime/web/base/action_handler.py b/keylime/web/base/action_handler.py -index b20de89..e7b5888 100644 +index 68dd30d..df37be0 100644 --- a/keylime/web/base/action_handler.py +++ b/keylime/web/base/action_handler.py -@@ -1,4 +1,5 @@ - import re -+import sys - import time - import traceback - from inspect import iscoroutinefunction -@@ -48,7 +49,11 @@ class ActionHandler(RequestHandler): +@@ -59,7 +59,7 @@ class ActionHandler(RequestHandler): + logger.error("An uncaught exception occurred while handling a request:") - # Take the list of strings returned by format_exception, where each string ends in a newline and may contain - # internal newlines, and split the concatenation of all the strings by newline -- message = "".join(traceback.format_exception(err)) -+ if sys.version_info < (3, 10): -+ message = "".join(traceback.format_exception(err, None, None)) + try: +- formatted_tb = traceback.format_exception(err) ++ formatted_tb = traceback.format_exception(type(err), err, err.__traceback__) + except NameError: + # Sometimes an exception cannot be printed with traceback.format_exception() for unknown reasons. + # If this occurs, manually construct the output using traceback.format_tb() +diff --git a/keylime/web/base/api_messages/api_error.py b/keylime/web/base/api_messages/api_error.py +index f751f6a..226986b 100644 +--- a/keylime/web/base/api_messages/api_error.py ++++ b/keylime/web/base/api_messages/api_error.py +@@ -1,5 +1,5 @@ + from types import MappingProxyType +-from typing import TYPE_CHECKING, Any, overload ++from typing import TYPE_CHECKING, Any, overload, Union + + import keylime.web.base.api_messages as api_messages # pylint: disable=consider-using-from-import # Avoid circular import + from keylime.web.base.api_messages.api_links import APILink, APILinksMixin +@@ -34,26 +34,30 @@ class APIError(APILinksMixin): + if not APIMessageHelpers.is_valid_name(args[0]): + raise InvalidMember("the given error code (i.e., 'api_code') is not a valid JSON:API member name") + +- self._api_code: str | None = None +- self._http_code: int | None = None +- self._detail: str | None = None +- self._source: dict[str, str] | None = None ++ self._api_code: Union[str, None] = None ++ self._http_code: Union[int, None] = None ++ self._detail: Union[str, None] = None ++ self._source: Union[dict[str, str], None] = None + self._links: dict[str, APILink] = {} + + self.set_api_code(args[0]) + +- match args[1:]: +- case (): +- pass +- case (http_code,) if isinstance(http_code, int): +- self.set_http_code(http_code) +- case (detail,) if isinstance(detail, str): +- self.set_detail(detail) +- case (http_code, detail): +- self.set_http_code(http_code) +- self.set_detail(detail) +- case _: +- raise TypeError(f"{self.__class__}() received invalid positional arguments") ++ __match_val = args[1:] ++ if isinstance(__match_val, (list, tuple)) and len(__match_val) == 0: ++ pass ++ elif (isinstance(__match_val, (list, tuple)) and len(__match_val) == 1) and isinstance(__match_val[0], int): ++ http_code = __match_val[0] ++ self.set_http_code(http_code) ++ elif (isinstance(__match_val, (list, tuple)) and len(__match_val) == 1) and isinstance(__match_val[0], str): ++ detail = __match_val[0] ++ self.set_detail(detail) ++ elif isinstance(__match_val, (list, tuple)) and len(__match_val) == 2: ++ http_code = __match_val[0] ++ detail = __match_val[1] ++ self.set_http_code(http_code) ++ self.set_detail(detail) + else: -+ message = "".join(traceback.format_exception(err)) -+ - lines = message.split("\n") ++ raise TypeError(f"{self.__class__}() received invalid positional arguments") - for line in lines: + # JSON:API features not currently implemented: + # - "id" member +@@ -107,12 +111,12 @@ class APIError(APILinksMixin): + return self + + def set_source(self, **kwargs: str) -> "APIError": +- match kwargs: +- case {"pointer": _} | {"parameter": _} | {"header": _}: +- self._source = kwargs +- return self +- case _: +- raise TypeError(f"{self.__class__}.set_source() received invalid keyword arguments") ++ __match_val_2 = kwargs ++ if isinstance(__match_val_2, dict) and "pointer" in __match_val_2 or isinstance(__match_val_2, dict) and "parameter" in __match_val_2 or isinstance(__match_val_2, dict) and "header" in __match_val_2: ++ self._source = kwargs ++ return self ++ else: ++ raise TypeError(f"{self.__class__}.set_source() received invalid keyword arguments") + + def clear_source(self) -> "APIError": + self._source = None +@@ -147,22 +151,22 @@ class APIError(APILinksMixin): + return output + + def send_via( +- self, controller: "Controller", *, code: int | None = None, status: str | None = None, stop_action: bool = True ++ self, controller: "Controller", *, code: Union[int, None] = None, status: Union[str, None] = None, stop_action: bool = True + ) -> None: + api_messages.APIMessageBody(self).send_via(controller, code=code, status=status, stop_action=stop_action) + + @property +- def api_code(self) -> str | None: ++ def api_code(self) -> Union[str, None]: + return self._api_code + + @property +- def http_code(self) -> int | None: ++ def http_code(self) -> Union[int, None]: + return self._http_code + + @property +- def detail(self) -> str | None: ++ def detail(self) -> Union[str, None]: + return self._detail + + @property +- def source(self) -> MappingProxyType[str, str] | None: ++ def source(self) -> Union[MappingProxyType[str, str], None]: + return MappingProxyType(self._source) if self._source else None +diff --git a/keylime/web/base/api_messages/api_links.py b/keylime/web/base/api_messages/api_links.py +index 7582bf0..c07d1cd 100644 +--- a/keylime/web/base/api_messages/api_links.py ++++ b/keylime/web/base/api_messages/api_links.py +@@ -1,6 +1,6 @@ + from collections.abc import Mapping + from types import MappingProxyType +-from typing import Any ++from typing import Any, Union + + from keylime.web.base.api_messages.api_message_helpers import APIMessageHelpers + from keylime.web.base.exceptions import InvalidMember +@@ -16,7 +16,7 @@ class APILink: + raise InvalidMember("link name is not a valid JSON:API member name") + + self._name = name +- self._href: str | None = None ++ self._href: Union[str, None] = None + + self.set_href(href) + +@@ -38,7 +38,7 @@ class APILink: + self._href = href + return self + +- def render(self) -> str | None: ++ def render(self) -> Union[str, None]: + return self.href + + @property +@@ -46,7 +46,7 @@ class APILink: + return self._name + + @property +- def href(self) -> str | None: ++ def href(self) -> Union[str, None]: + return self._href + + +@@ -84,7 +84,7 @@ class APILinksMixin: + self._links.clear() + return self + +- def render_links(self) -> dict[str, str | None]: ++ def render_links(self) -> dict[str, Union[str, None]]: + return {name: link.render() for name, link in self.links.items()} + + @property +diff --git a/keylime/web/base/api_messages/api_message_body.py b/keylime/web/base/api_messages/api_message_body.py +index 5ef45ba..c30b084 100644 +--- a/keylime/web/base/api_messages/api_message_body.py ++++ b/keylime/web/base/api_messages/api_message_body.py +@@ -2,7 +2,7 @@ from __future__ import annotations + + from collections.abc import Mapping, Sequence + from types import MappingProxyType +-from typing import TYPE_CHECKING, Any ++from typing import TYPE_CHECKING, Any, Union + + from keylime import keylime_logging + from keylime.models.base import BasicModel +@@ -51,11 +51,11 @@ class APIMessageBody(APILinksMixin, APIMetaMixin): + return message_body + + @classmethod +- def from_record_errors(cls, records: BasicModel | Sequence[BasicModel]) -> "APIMessageBody": ++ def from_record_errors(cls, records: Union[BasicModel, Sequence[BasicModel]]) -> "APIMessageBody": + return cls().add_record_errors(records) + +- def __init__(self, *items: base.APIResource | APIError | APIMeta | APILink): +- self._data: base.APIResource | list[base.APIResource] | None = None ++ def __init__(self, *items: Union[base.APIResource, APIError, APIMeta, APILink]): ++ self._data: Union[base.APIResource, list[base.APIResource], None] = None + self._errors: list[APIError] = [] + self._meta: dict[str, APIMeta] = {} + self._links: dict[str, APILink] = {} +@@ -91,7 +91,7 @@ class APIMessageBody(APILinksMixin, APIMetaMixin): + self._data.append(resource) + return self + +- def load_resources(self, data: Mapping[str, Any] | Sequence[Mapping[str, Any]]) -> "APIMessageBody": ++ def load_resources(self, data: Union[Mapping[str, Any], Sequence[Mapping[str, Any]]]) -> "APIMessageBody": + if not isinstance(data, (Mapping, Sequence)): + raise InvalidMember("the JSON:API 'data' member must be a mapping or sequence") + +@@ -148,7 +148,7 @@ class APIMessageBody(APILinksMixin, APIMetaMixin): + + return self + +- def add_record_errors(self, records: BasicModel | Sequence[BasicModel]) -> "APIMessageBody": ++ def add_record_errors(self, records: Union[BasicModel, Sequence[BasicModel]]) -> "APIMessageBody": + errors: dict[str, list[str]] = {} + single_resource = False + +@@ -193,10 +193,10 @@ class APIMessageBody(APILinksMixin, APIMetaMixin): + self._errors.clear() + return self + +- def get_errors(self, code: str | int) -> list[APIError]: ++ def get_errors(self, code: Union[str, int]) -> list[APIError]: + return [error for error in self.errors if code in (error.api_code, error.http_code)] + +- def include(self, *items: base.APIResource | APIError | APIMeta | APILink) -> "APIMessageBody": ++ def include(self, *items: Union[base.APIResource, APIError, APIMeta, APILink]) -> "APIMessageBody": + for item in items: + if isinstance(item, base.APIResource): + self.add_resource(item) +@@ -242,7 +242,7 @@ class APIMessageBody(APILinksMixin, APIMetaMixin): + + return output + +- def _get_current_path(self) -> str | None: ++ def _get_current_path(self) -> Union[str, None]: + current_location = self.links.get("self") + + if not current_location: +@@ -250,7 +250,7 @@ class APIMessageBody(APILinksMixin, APIMetaMixin): + + return base.Route.make_abs_path(current_location.href) # type: ignore[no-any-return, arg-type] + +- def _get_resource_path(self) -> str | None: ++ def _get_resource_path(self) -> Union[str, None]: + resource_location = self.data.links.get("self") if isinstance(self.data, base.APIResource) else None + + if not resource_location: +@@ -302,7 +302,7 @@ class APIMessageBody(APILinksMixin, APIMetaMixin): + logger.warning(" • %s: %s", error.api_code, error.detail) + + def send_via( +- self, controller: "Controller", *, code: int | None = None, status: str | None = None, stop_action: bool = True ++ self, controller: "Controller", *, code: Union[int, None] = None, status: Union[str, None] = None, stop_action: bool = True + ) -> None: + if not isinstance(controller, base.Controller): + raise TypeError( +@@ -328,7 +328,7 @@ class APIMessageBody(APILinksMixin, APIMetaMixin): + raise StopAction + + @property +- def data(self) -> base.APIResource | list[base.APIResource] | None: ++ def data(self) -> Union[base.APIResource, list[base.APIResource], None]: + if isinstance(self._data, list): + return self._data.copy() + return self._data +diff --git a/keylime/web/base/api_messages/api_resource.py b/keylime/web/base/api_messages/api_resource.py +index efd9599..8623d0b 100644 +--- a/keylime/web/base/api_messages/api_resource.py ++++ b/keylime/web/base/api_messages/api_resource.py +@@ -1,6 +1,6 @@ + from collections.abc import Mapping + from types import MappingProxyType +-from typing import TYPE_CHECKING, Any, overload ++from typing import TYPE_CHECKING, Any, overload, Union + + import keylime.web.base.api_messages as api_messages # pylint: disable=consider-using-from-import # Avoid circular import + from keylime.web.base.api_messages.api_links import APILink, APILinksMixin +@@ -58,26 +58,30 @@ class APIResource(APILinksMixin, APIMetaMixin): + if not args: + raise MissingMember("no 'type' given for a JSON:API resource") + +- self._type: str | None = None +- self._id: str | None = None ++ self._type: Union[str, None] = None ++ self._id: Union[str, None] = None + self._attributes: dict[str, Any] = {} + self._links: dict[str, APILink] = {} + self._meta: dict[str, APIMeta] = {} + + self.set_type(args[0]) + +- match args[1:]: +- case (): +- pass +- case (res_id,) if isinstance(res_id, str): +- self.set_id(res_id) +- case (attributes,) if isinstance(attributes, dict): +- self.load_attributes(attributes) +- case (res_id, attributes): +- self.load_attributes(attributes) +- self.set_id(res_id) +- case _: +- raise TypeError(f"{self.__class__}() received invalid positional arguments") ++ __match_val = args[1:] ++ if isinstance(__match_val, (list, tuple)) and len(__match_val) == 0: ++ pass ++ elif (isinstance(__match_val, (list, tuple)) and len(__match_val) == 1) and isinstance(__match_val[0], str): ++ res_id = __match_val[0] ++ self.set_id(res_id) ++ elif (isinstance(__match_val, (list, tuple)) and len(__match_val) == 1) and isinstance(__match_val[0], dict): ++ attributes = __match_val[0] ++ self.load_attributes(attributes) ++ elif isinstance(__match_val, (list, tuple)) and len(__match_val) == 2: ++ res_id = __match_val[0] ++ attributes = __match_val[1] ++ self.load_attributes(attributes) ++ self.set_id(res_id) ++ else: ++ raise TypeError(f"{self.__class__}() received invalid positional arguments") + + # JSON:API features not currently implemented: + # - "relationships" member +@@ -143,7 +147,7 @@ class APIResource(APILinksMixin, APIMetaMixin): + self._attributes.clear() + return self + +- def include(self, *items: APILink | APIMeta) -> "APIResource": ++ def include(self, *items: Union[APILink, APIMeta]) -> "APIResource": + for item in items: + if isinstance(item, APILink): + self.add_link(item) +@@ -172,16 +176,16 @@ class APIResource(APILinksMixin, APIMetaMixin): + return output + + def send_via( +- self, controller: "Controller", *, code: int | None = None, status: str | None = None, stop_action: bool = True ++ self, controller: "Controller", *, code: Union[int, None] = None, status: Union[str, None] = None, stop_action: bool = True + ) -> None: + api_messages.APIMessageBody(self).send_via(controller, code=code, status=status, stop_action=stop_action) + + @property +- def type(self) -> str | None: ++ def type(self) -> Union[str, None]: + return self._type + + @property +- def id(self) -> str | None: ++ def id(self) -> Union[str, None]: + return self._id + + @property diff --git a/keylime/web/base/controller.py b/keylime/web/base/controller.py -index f1ac3c5..153535e 100644 +index bd30130..3ee03c3 100644 --- a/keylime/web/base/controller.py +++ b/keylime/web/base/controller.py -@@ -2,7 +2,7 @@ import http.client - import json +@@ -3,7 +3,7 @@ import json import re + from functools import wraps from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Mapping, Optional, Sequence, TypeAlias, Union +from typing import TYPE_CHECKING, Any, Mapping, Optional, Sequence, Union from tornado.escape import parse_qs_bytes from tornado.httputil import parse_body_arguments -@@ -15,14 +15,16 @@ if TYPE_CHECKING: +@@ -20,14 +20,14 @@ if TYPE_CHECKING: from keylime.models.base.basic_model import BasicModel from keylime.web.base.action_handler import ActionHandler @@ -506,19 +1121,17 @@ index f1ac3c5..153535e 100644 -Params: TypeAlias = Mapping[str, Union[str, bytes, Sequence[str | bytes], JSONObjectConvertible, JSONArrayConvertible]] +PathParams = Mapping[str, str] +QueryParams = Mapping[str, Union[str, Sequence[str]]] -+MultipartParams = Mapping[str, Union[str, bytes, Union[Sequence[str], Sequence[bytes]]]] ++MultipartParams = Mapping[str, Union[str, bytes, Sequence[Union[str, bytes]]]] +FormParams = Union[QueryParams, MultipartParams] +JSONConvertible = Union[str, int, float, bool, None, "JSONObjectConvertible", "JSONArrayConvertible"] +JSONObjectConvertible = Mapping[str, JSONConvertible] +JSONArrayConvertible = Sequence[JSONConvertible] # pyright: ignore[reportInvalidTypeForm] -+Params = Mapping[ -+ str, Union[str, bytes, Union[Sequence[str], Sequence[bytes]], JSONObjectConvertible, JSONArrayConvertible] -+] ++Params = Mapping[str, Union[str, bytes, Sequence[Union[str, bytes]], JSONObjectConvertible, JSONArrayConvertible]] class Controller: -@@ -77,7 +79,7 @@ class Controller: - VERSION_REGEX = re.compile("^\\/v(\\d+)(?:\\.(\\d+))*") +@@ -123,7 +123,7 @@ class Controller: + return require_json_api_wrapper @staticmethod - def decode_url_query(query: str | bytes) -> QueryParams: @@ -526,7 +1139,16 @@ index f1ac3c5..153535e 100644 """Parses a binary query string (whether from a URL or HTTP body) into a dict of Unicode strings. If multiple instances of the same key are present in the string, their values are collected into a list. -@@ -135,8 +137,8 @@ class Controller: +@@ -160,7 +160,7 @@ class Controller: + :raises: :class:`ParamDecodeError`: form data is malformed + """ + collated_params: dict[str, list[bytes]] = {} +- decoded_params: dict[str, Union[str, bytes, Sequence[str | bytes]]] = {} ++ decoded_params: dict[str, Union[str, bytes, Sequence[Union[str, bytes]]]] = {} + + try: + parse_body_arguments(content_type, form, collated_params, {}) +@@ -181,8 +181,8 @@ class Controller: @staticmethod def prepare_http_body( @@ -537,13 +1159,14 @@ index f1ac3c5..153535e 100644 """Prepares an object to be included in the body of an HTTP request or response and infers the appropriate media type unless provided. ``body`` will be serialised into JSON if it contains a ``dict`` or ``list`` which is serialisable unless a ``content_type`` other than ``"application/json"`` is provided. -@@ -155,32 +157,34 @@ class Controller: +@@ -201,34 +201,34 @@ class Controller: if content_type: content_type = content_type.lower().strip() - body_out: Optional[bytes | Any] -- content_type_out: Optional[str] -- ++ body_out: Optional[Union[bytes, Any]] + content_type_out: Optional[str] + - match (body, content_type): - case (None, _): - body_out = None @@ -552,19 +1175,8 @@ index f1ac3c5..153535e 100644 - body_out = b"" - content_type_out = "text/plain; charset=utf-8" - case (_, "text/plain"): -+ body_out: Optional[bytes | Any] = None -+ content_type_out: Optional[str] = None -+ -+ if body is None: -+ body_out = None -+ content_type_out = content_type -+ elif body == "": -+ body_out = b"" -+ content_type_out = "text/plain; charset=utf-8" -+ else: -+ if content_type == "text/plain": - body_out = str(body).encode("utf-8") - content_type_out = "text/plain; charset=utf-8" +- body_out = str(body).encode("utf-8") +- content_type_out = "text/plain; charset=utf-8" - case (_, "application/json") if isinstance(body, str): - body_out = body.encode("utf-8") - content_type_out = "application/json" @@ -578,51 +1190,187 @@ index f1ac3c5..153535e 100644 - body_out = json.dumps(body, allow_nan=False, indent=4).encode("utf-8") - content_type_out = "application/json" - case (_, _): -+ elif content_type == "application/json": -+ if isinstance(body, str): -+ body_out = body.encode("utf-8") -+ content_type_out = "application/json" -+ else: -+ body_out = json.dumps(body, allow_nan=False, indent=4).encode("utf-8") -+ content_type_out = "application/json" -+ elif content_type is None: -+ if isinstance(body, str): -+ body_out = body.encode("utf-8") -+ content_type_out = "text/plain; charset=utf-8" -+ elif isinstance(body, (dict, list)): -+ body_out = json.dumps(body, allow_nan=False, indent=4).encode("utf-8") -+ content_type_out = "application/json" -+ else: - body_out = body - content_type_out = content_type +- body_out = body +- content_type_out = content_type ++ __match_val = (body, content_type) ++ if isinstance(__match_val, (list, tuple)) and len(__match_val) == 2 and __match_val[0] is None: ++ body_out = None ++ content_type_out = content_type ++ elif isinstance(__match_val, (list, tuple)) and len(__match_val) == 2 and __match_val[0] == "": ++ body_out = b"" ++ content_type_out = "text/plain; charset=utf-8" ++ elif isinstance(__match_val, (list, tuple)) and len(__match_val) == 2 and __match_val[1] == "text/plain": ++ body_out = str(body).encode("utf-8") ++ content_type_out = "text/plain; charset=utf-8" ++ elif (isinstance(__match_val, (list, tuple)) and len(__match_val) == 2 and __match_val[1] == "application/json") and isinstance(body, str): ++ body_out = body.encode("utf-8") ++ content_type_out = "application/json" ++ elif isinstance(__match_val, (list, tuple)) and len(__match_val) == 2 and __match_val[1] == "application/json": ++ body_out = json.dumps(body, allow_nan=False, indent=4).encode("utf-8") ++ content_type_out = "application/json" ++ elif (isinstance(__match_val, (list, tuple)) and len(__match_val) == 2 and __match_val[1] is None) and isinstance(body, str): ++ body_out = body.encode("utf-8") ++ content_type_out = "text/plain; charset=utf-8" ++ elif (isinstance(__match_val, (list, tuple)) and len(__match_val) == 2 and __match_val[1] is None) and isinstance(body, (dict, list)): ++ body_out = json.dumps(body, allow_nan=False, indent=4).encode("utf-8") ++ content_type_out = "application/json" ++ elif isinstance(__match_val, (list, tuple)) and len(__match_val) == 2: ++ body_out = body ++ content_type_out = content_type -@@ -248,7 +252,7 @@ class Controller: + return (body_out, content_type_out) + +@@ -314,7 +314,7 @@ class Controller: self, code: int = 200, status: Optional[str] = None, - data: Optional[JSONObjectConvertible | JSONArrayConvertible] = None, + data: Optional[Union[JSONObjectConvertible, JSONArrayConvertible]] = None, + suppress_jsonapi_error: bool = False, + suppress_version_error: bool = False, ) -> None: - """Converts a Python data structure to JSON and wraps it in the following boilerplate JSON object which is - returned by all v2 endpoints: -diff --git a/tox.ini b/tox.ini -index 031ac54..ce3974c 100644 ---- a/tox.ini -+++ b/tox.ini -@@ -51,3 +51,13 @@ commands = black --diff ./keylime ./test - deps = - isort - commands = isort --diff --check ./keylime ./test -+ -+ -+[testenv:pylint39] -+basepython = python3.9 -+deps = -+ -r{toxinidir}/requirements.txt -+ -r{toxinidir}/test-requirements.txt -+ pylint -+commands = bash scripts/check_codestyle.sh -+allowlist_externals = bash +@@ -528,7 +528,7 @@ class Controller: + """ + if not self._form_params: + content_type = self.request_headers.get("Content-Type") +- form_params: Mapping[str, Union[str, bytes, Sequence[str | bytes]]] = {} ++ form_params: Mapping[str, Union[str, bytes, Sequence[Union[str, bytes]]]] = {} + + if content_type and content_type.startswith("application/x-www-form-urlencoded"): + form_params = Controller.decode_url_query(self.request_body) +diff --git a/keylime/web/base/route.py b/keylime/web/base/route.py +index b49a603..dc2b771 100644 +--- a/keylime/web/base/route.py ++++ b/keylime/web/base/route.py +@@ -1,6 +1,6 @@ + import re + from inspect import iscoroutinefunction +-from typing import TYPE_CHECKING, Any, Mapping, Optional ++from typing import TYPE_CHECKING, Any, Mapping, Optional, Union + from urllib.parse import urljoin, urlparse, urlunparse + + import keylime.web.base as base # pylint: disable=consider-using-from-import # Avoid circular import +@@ -238,7 +238,7 @@ class Route: + # pylint: disable=no-else-raise + + pattern_segments = Route.split_path(self.pattern) +- parsed_pattern: list[str | dict[str, str]] = [] ++ parsed_pattern: list[Union[str, dict[str, str]]] = [] + + for segment in pattern_segments: + delimiter_count = segment.count(":") +diff --git a/keylime/web/base/server.py b/keylime/web/base/server.py +index fe9ccbf..9ac3a3c 100644 +--- a/keylime/web/base/server.py ++++ b/keylime/web/base/server.py +@@ -4,7 +4,7 @@ import signal + from abc import ABC, abstractmethod + from functools import wraps + from ssl import CERT_OPTIONAL +-from typing import TYPE_CHECKING, Any, Callable, Optional ++from typing import TYPE_CHECKING, Any, Callable, Optional, Union + + import tornado + +@@ -202,7 +202,7 @@ class Server(ABC): + """ + # Set defaults for server options + self.__component: str = "unknown" # Component name (e.g., "verifier", "registrar") +- self.__config_component: str | None = None ++ self.__config_component: Union[str, None] = None + self.__operating_mode = None + self.__bind_interface: str = "127.0.0.1" + self.__http_port: Optional[int] = 80 +@@ -477,27 +477,31 @@ class Server(ABC): + if "fallback" in kwargs: + del kwargs["fallback"] + +- match kwargs: +- case {"value": value}: +- pass +- +- case {"from_config": config_name} if isinstance(config_name, str): +- value = config.get(self.config_component, config_name, fallback=fallback) # type: ignore +- +- case {"from_config": (config_name, data_type)} if data_type is str: +- value = config.get(self.config_component, config_name, fallback=fallback) # type: ignore +- +- case {"from_config": (config_name, data_type)} if data_type is int: +- value = config.getint(self.config_component, config_name, fallback=fallback) # type: ignore +- +- case {"from_config": (config_name, data_type)} if data_type is float: +- value = config.getfloat(self.config_component, config_name, fallback=fallback) # type: ignore +- +- case {"from_config": (config_name, data_type)} if data_type is bool: +- value = config.getboolean(self.config_component, config_name, fallback=fallback) # type: ignore +- +- case _: +- raise TypeError(f"invalid arguments given when setting option '{name}' for {self.__class__.__name__}") ++ __match_val = kwargs ++ if isinstance(__match_val, dict) and "value" in __match_val: ++ value = __match_val["value"] ++ pass ++ elif (isinstance(__match_val, dict) and "from_config" in __match_val) and isinstance(__match_val["from_config"], str): ++ config_name = __match_val["from_config"] ++ value = config.get(self.config_component, config_name, fallback=fallback) # type: ignore ++ elif (isinstance(__match_val, dict) and "from_config" in __match_val) and __match_val["from_config"][1] is str: ++ config_name = __match_val["from_config"][0] ++ data_type = __match_val["from_config"][1] ++ value = config.get(self.config_component, config_name, fallback=fallback) # type: ignore ++ elif (isinstance(__match_val, dict) and "from_config" in __match_val) and __match_val["from_config"][1] is int: ++ config_name = __match_val["from_config"][0] ++ data_type = __match_val["from_config"][1] ++ value = config.getint(self.config_component, config_name, fallback=fallback) # type: ignore ++ elif (isinstance(__match_val, dict) and "from_config" in __match_val) and __match_val["from_config"][1] is float: ++ config_name = __match_val["from_config"][0] ++ data_type = __match_val["from_config"][1] ++ value = config.getfloat(self.config_component, config_name, fallback=fallback) # type: ignore ++ elif (isinstance(__match_val, dict) and "from_config" in __match_val) and __match_val["from_config"][1] is bool: ++ config_name = __match_val["from_config"][0] ++ data_type = __match_val["from_config"][1] ++ value = config.getboolean(self.config_component, config_name, fallback=fallback) # type: ignore ++ else: ++ raise TypeError(f"invalid arguments given when setting option '{name}' for {self.__class__.__name__}") + + setattr(self, attr_name, value or fallback) + +diff --git a/test/test_authorization_integration.py b/test/test_authorization_integration.py +index 698c8ae..aa466af 100644 +--- a/test/test_authorization_integration.py ++++ b/test/test_authorization_integration.py +@@ -17,6 +17,7 @@ from keylime.authorization.provider import Action, AuthorizationRequest, Authori + from keylime.models.base.types import Timestamp + from keylime.shared_data import cleanup_global_shared_memory, get_shared_memory + from keylime.web.base.action_handler import ActionHandler ++from typing import Union + + + class TestIdentityExtraction(unittest.TestCase): +@@ -32,7 +33,7 @@ class TestIdentityExtraction(unittest.TestCase): + """Clean up after tests.""" + cleanup_global_shared_memory() + +- def _create_handler_with_auth_header(self, auth_header: str | None) -> ActionHandler: ++ def _create_handler_with_auth_header(self, auth_header: Union[str, None]) -> ActionHandler: + """Create a mock ActionHandler with specified Authorization header.""" + handler = ActionHandler.__new__(ActionHandler) + handler.request = MagicMock() +@@ -229,12 +230,12 @@ class TestAuthorizationCheckFlow(unittest.TestCase): + + def _create_handler_with_route( + self, +- auth_header: str | None = None, +- mtls_cn: str | None = None, ++ auth_header: Union[str, None] = None, ++ mtls_cn: Union[str, None] = None, + requires_auth: bool = True, + route_method: str = "GET", + route_pattern: str = "/agents", +- auth_action: Action | None = None, ++ auth_action: Union[Action, None] = None, + ) -> ActionHandler: + """Create a mock ActionHandler with specified route and auth.""" + handler = ActionHandler.__new__(ActionHandler) -- -2.47.1 +2.54.0 diff --git a/0005-Restore-RHEL-9-version-of-create_allowlist.sh.patch b/0002-Restore-RHEL-9-version-of-create_allowlist.sh.patch similarity index 97% rename from 0005-Restore-RHEL-9-version-of-create_allowlist.sh.patch rename to 0002-Restore-RHEL-9-version-of-create_allowlist.sh.patch index bebd40f..50e4b54 100644 --- a/0005-Restore-RHEL-9-version-of-create_allowlist.sh.patch +++ b/0002-Restore-RHEL-9-version-of-create_allowlist.sh.patch @@ -1,7 +1,7 @@ -From c60460eccab93863dbd1fd0b748e5a275c8e6737 Mon Sep 17 00:00:00 2001 +From 1f73ef67a77d7355df524b6c120d6247f8f40e7f Mon Sep 17 00:00:00 2001 From: Sergio Correia -Date: Tue, 3 Jun 2025 21:29:15 +0100 -Subject: [PATCH 5/6] Restore RHEL-9 version of create_allowlist.sh +Date: Mon, 1 Jun 2026 13:56:43 +0100 +Subject: [PATCH 2/4] Restore RHEL-9 version of create_allowlist.sh Signed-off-by: Sergio Correia --- @@ -9,7 +9,7 @@ Signed-off-by: Sergio Correia 1 file changed, 104 insertions(+), 231 deletions(-) diff --git a/scripts/create_runtime_policy.sh b/scripts/create_runtime_policy.sh -index 90ba50b..c0b641d 100755 +index 462073a..c0b641d 100755 --- a/scripts/create_runtime_policy.sh +++ b/scripts/create_runtime_policy.sh @@ -1,282 +1,155 @@ @@ -303,7 +303,7 @@ index 90ba50b..c0b641d 100755 - # If we are on an ostree system change where we look for initramfs image - loc=$(grep -E "/ostree/[^/]([^/]*)" -o /proc/cmdline | head -n 1 | cut -d / -f 3) - INITRAMFS_LOC="/boot/ostree/${loc}/" -- announce "--- The location of initramfs was overriden from \"${X}\" to \"$INITRAMFS_LOC\"" +- announce "--- The location of initramfs was overridden from \"${X}\" to \"$INITRAMFS_LOC\"" - fi - - announce "--- Creating allowlist for init ram disks found under \"$INITRAMFS_LOC\" to $ALLOWLIST_DIR/${OUTPUT} ..." @@ -400,5 +400,5 @@ index 90ba50b..c0b641d 100755 +# Clean up +rm -rf /tmp/ima -- -2.47.1 +2.54.0 diff --git a/0002-tests-fix-rpm-repo-tests-from-create-runtime-policy.patch b/0002-tests-fix-rpm-repo-tests-from-create-runtime-policy.patch deleted file mode 100644 index 5735f6c..0000000 --- a/0002-tests-fix-rpm-repo-tests-from-create-runtime-policy.patch +++ /dev/null @@ -1,58 +0,0 @@ -From 5c5c7f7f7180111485b24061af4c0395476958b5 Mon Sep 17 00:00:00 2001 -From: Sergio Correia -Date: Thu, 22 May 2025 11:25:15 -0400 -Subject: [PATCH 2/6] tests: fix rpm repo tests from create-runtime-policy - -Signed-off-by: Sergio Correia ---- - .../create-runtime-policy/setup-rpm-tests | 28 +++++++++++++------ - 1 file changed, 20 insertions(+), 8 deletions(-) - -diff --git a/test/data/create-runtime-policy/setup-rpm-tests b/test/data/create-runtime-policy/setup-rpm-tests -index 708438c..b62729b 100755 ---- a/test/data/create-runtime-policy/setup-rpm-tests -+++ b/test/data/create-runtime-policy/setup-rpm-tests -@@ -217,20 +217,32 @@ create_rpm() { - # https://github.com/rpm-software-management/rpm/commit/96467dce18f264b278e17ffe1859c88d9b5aa4b6 - _pkgname="DUMMY-${_name}-${_version}-${_rel}.noarch.rpm" - -- _expected_pkg="${RPMSDIR}/noarch/${_pkgname}" -- [ -e "${_expected_pkg}" ] && return 0 -+ # For some reason, it may not store the built package within the -+ # noarch directory, but directly in RPMS, so let's check both -+ # locations. -+ _expected_pkg="${RPMSDIR}/noarch/${_pkgname} ${RPMSDIR}/${_pkgname}" -+ for _expected in ${_expected_pkg}; do -+ if [ -e "${_expected}" ]; then -+ echo "(create_rpm) CREATED RPM: ${_expected}" >&2 -+ return 0 -+ fi -+ done - - # OK, the package was not built where it should. Let us see if - # it was built in ~/rpmbuild instead, and if that is the case, - # copy it to the expected location. -- _bad_location_pkg="${HOME}/rpmbuild/RPMS/noarch/${_pkgname}" -- if [ -e "${_bad_location_pkg}" ]; then -- echo "WARNING: the package ${_pkgname} was built into ~/rpmbuild despite rpmbuild being instructed to build it at a different location. Probably a fallout from https://github.com/rpm-software-management/rpm/commit/96467dce" >&2 -- install -D -m644 "${_bad_location_pkg}" "${_expected_pkg}" -- return 0 -- fi -+ _bad_location_pkg="${HOME}/rpmbuild/RPMS/noarch/${_pkgname} ${HOME}/rpmbuild/RPMS/${_pkgname}" -+ for _bad_l in ${_bad_location_pkg}; do -+ if [ -e "${_bad_l}" ]; then -+ echo "WARNING: the package ${_pkgname} was built into ~/rpmbuild despite rpmbuild being instructed to build it at a different location. Probably a fallout from https://github.com/rpm-software-management/rpm/commit/96467dce" >&2 -+ install -D -m644 "${_bad_l}" "${RPMSDIR}/noarch/${_pkgname}" -+ echo "(create_rpm) CREATED RPM: ${RPMSDIR}/noarch/${_pkgname}" >&2 -+ return 0 -+ fi -+ done - - # Should not be here. -+ echo "create_rpm() ended with error; probably an issue with the location where the RPMs were built" >&2 - return 1 - } - --- -2.47.1 - diff --git a/0006-Revert-default-server_key_password-for-verifier-regi.patch b/0003-Revert-default-server_key_password-for-verifier-regi.patch similarity index 90% rename from 0006-Revert-default-server_key_password-for-verifier-regi.patch rename to 0003-Revert-default-server_key_password-for-verifier-regi.patch index 48d8420..adb7833 100644 --- a/0006-Revert-default-server_key_password-for-verifier-regi.patch +++ b/0003-Revert-default-server_key_password-for-verifier-regi.patch @@ -1,7 +1,7 @@ -From 733db4036f2142152795fc51b761f05e39594b08 Mon Sep 17 00:00:00 2001 +From 8356c4b5af97afa7a8ba57998865767793de2b2f Mon Sep 17 00:00:00 2001 From: Sergio Correia Date: Tue, 27 May 2025 09:31:54 +0000 -Subject: [PATCH 6/6] Revert "default" server_key_password for +Subject: [PATCH 3/4] Revert "default" server_key_password for verifier/registrar Signed-off-by: Sergio Correia @@ -11,7 +11,7 @@ Signed-off-by: Sergio Correia 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/templates/2.0/mapping.json b/templates/2.0/mapping.json -index 80dcdde..8fce124 100644 +index 57f72d9..0004c4f 100644 --- a/templates/2.0/mapping.json +++ b/templates/2.0/mapping.json @@ -232,7 +232,7 @@ @@ -33,7 +33,7 @@ index 80dcdde..8fce124 100644 "server_cert": { "section": "registrar", diff --git a/templates/2.1/mapping.json b/templates/2.1/mapping.json -index 956a53a..88e3fb6 100644 +index 6f5c11f..5b5f895 100644 --- a/templates/2.1/mapping.json +++ b/templates/2.1/mapping.json @@ -262,7 +262,7 @@ @@ -62,5 +62,5 @@ index 956a53a..88e3fb6 100644 \ No newline at end of file +} -- -2.47.1 +2.54.0 diff --git a/0003-tests-skip-measured-boot-related-tests-for-s390x-and.patch b/0003-tests-skip-measured-boot-related-tests-for-s390x-and.patch deleted file mode 100644 index 8cf9b37..0000000 --- a/0003-tests-skip-measured-boot-related-tests-for-s390x-and.patch +++ /dev/null @@ -1,52 +0,0 @@ -From 4e7cd6b75de27897ecc8e7329732cd945f7adfd0 Mon Sep 17 00:00:00 2001 -From: Sergio Correia -Date: Thu, 22 May 2025 18:27:04 +0100 -Subject: [PATCH 3/6] tests: skip measured-boot related tests for s390x and - ppc64le - -Signed-off-by: Sergio Correia ---- - test/test_create_mb_policy.py | 2 ++ - test/test_mba_parsing.py | 2 ++ - 2 files changed, 4 insertions(+) - -diff --git a/test/test_create_mb_policy.py b/test/test_create_mb_policy.py -index eaed0e3..b00d8e7 100644 ---- a/test/test_create_mb_policy.py -+++ b/test/test_create_mb_policy.py -@@ -5,6 +5,7 @@ Copyright 2024 Red Hat, Inc. - - import argparse - import os -+import platform - import unittest - - from keylime.policy import create_mb_policy -@@ -12,6 +13,7 @@ from keylime.policy import create_mb_policy - DATA_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "data", "create-mb-policy")) - - -+@unittest.skipIf(platform.machine() in ["ppc64le", "s390x"], "ppc64le and s390x are not supported") - class CreateMeasuredBootPolicy_Test(unittest.TestCase): - def test_event_to_sha256(self): - test_cases = [ -diff --git a/test/test_mba_parsing.py b/test/test_mba_parsing.py -index 670a602..e157116 100644 ---- a/test/test_mba_parsing.py -+++ b/test/test_mba_parsing.py -@@ -1,10 +1,12 @@ - import os -+import platform - import unittest - - from keylime.common.algorithms import Hash - from keylime.mba import mba - - -+@unittest.skipIf(platform.machine() in ["ppc64le", "s390x"], "ppc64le and s390x are not supported") - class TestMBAParsing(unittest.TestCase): - def test_parse_bootlog(self): - """Test parsing binary measured boot event log""" --- -2.47.1 - diff --git a/0004-Disable-push-model-agent-driven-attestation-for-RHEL.patch b/0004-Disable-push-model-agent-driven-attestation-for-RHEL.patch new file mode 100644 index 0000000..e55efdb --- /dev/null +++ b/0004-Disable-push-model-agent-driven-attestation-for-RHEL.patch @@ -0,0 +1,192 @@ +From ea7db68f2fd12737d156a28cfc088e244ac14489 Mon Sep 17 00:00:00 2001 +From: Sergio Correia +Date: Mon, 6 Jul 2026 08:23:50 +0000 +Subject: [PATCH 4/4] Disable push model (agent-driven attestation) for RHEL 9 + +Push mode is not yet supported in this build. Gate it off at entry +points: remove API v3.0 from supported versions, hardcode verifier +operating mode to pull, remove --push-model tenant CLI flag. Push +code remains in tree as dead code for easy re-enablement later. + +Signed-off-by: Sergio Correia +--- + keylime/api_version.py | 4 ++-- + keylime/cmd/verifier.py | 11 +++++------ + keylime/tenant.py | 23 +---------------------- + keylime/web/verifier_server.py | 2 +- + test/test_registrar_server.py | 31 ------------------------------- + test/test_version_controller.py | 6 +++--- + 6 files changed, 12 insertions(+), 65 deletions(-) + +diff --git a/keylime/api_version.py b/keylime/api_version.py +index df65736..5773be7 100644 +--- a/keylime/api_version.py ++++ b/keylime/api_version.py +@@ -7,8 +7,8 @@ from packaging import version + VersionType = Union[int, float, str] + + CURRENT_VERSION: str = "2.6" +-VERSIONS: List[str] = ["1.0", "2.0", "2.1", "2.2", "2.3", "2.4", "2.5", "2.6", "3.0"] +-LATEST_VERSIONS: Dict[str, str] = {"1": "1.0", "2": "2.6", "3": "3.0"} ++VERSIONS: List[str] = ["1.0", "2.0", "2.1", "2.2", "2.3", "2.4", "2.5", "2.6"] ++LATEST_VERSIONS: Dict[str, str] = {"1": "1.0", "2": "2.6"} + DEPRECATED_VERSIONS: List[str] = ["1.0"] + + +diff --git a/keylime/cmd/verifier.py b/keylime/cmd/verifier.py +index 1e6cc44..8f2730d 100644 +--- a/keylime/cmd/verifier.py ++++ b/keylime/cmd/verifier.py +@@ -10,14 +10,13 @@ logger = keylime_logging.init_logging("verifier") + + def _log_startup_info() -> None: + mode = config.get("verifier", "mode", fallback="pull") +- logger.info("Starting Keylime verifier in %s mode...", mode.upper()) +- +- # Temporary warning when enabling push mode + if mode == "push": +- logger.warning( +- "Push mode is experimental. Please report issues at " +- "https://github.com/keylime/keylime/issues/?q=label:push-mode" ++ logger.error( ++ "Push mode (agent-driven attestation) is not supported in this build. " ++ "Falling back to pull mode." + ) ++ mode = "pull" ++ logger.info("Starting Keylime verifier in %s mode...", mode.upper()) + + # Log REST API versions supported by the verifier + api_version.log_api_versions(logger) +diff --git a/keylime/tenant.py b/keylime/tenant.py +index 4cb3698..b905b4e 100644 +--- a/keylime/tenant.py ++++ b/keylime/tenant.py +@@ -337,15 +337,7 @@ class Tenant: + if self.agent_port is None and self.registrar_data["port"] is not None: + self.agent_port = self.registrar_data["port"] + +- # Set push_model based on explicit --push-model flag +- # Default to PULL mode (False) if flag not provided +- if not args.get("push_model", False): +- self.push_model = False +- else: +- self.push_model = True +- +- # If ip/port are still None, that's okay for push-mode (verifier will handle it) +- # The verifier's mode config determines whether ip/port are required ++ self.push_model = False + + # Initialize ID strings early (will be updated after API detection with full info) + self.agent_fid_str = f"Agent {self.agent_uuid}" +@@ -1751,13 +1743,6 @@ def main() -> None: + "addmbpolicy,showmbpolicy,deletembpolicy,updatembpolicy," + "listmbpolicy. defaults to add", + ) +- parser.add_argument( +- "--push-model", +- action="store_true", +- dest="push_model", +- default=False, +- help="Enable push model (avoid requests to keylime-agent)", +- ) + parser.add_argument( + "-t", "--targethost", action="store", dest="agent_ip", help="the IP address of the host to provision" + ) +@@ -1983,12 +1968,6 @@ def main() -> None: + config.check_version("tenant", logger=logger) + + mytenant = Tenant() +- # Set push_model based on explicit --push-model flag +- # This must be done before dispatching to commands (status, add, etc.) +- if hasattr(args, "push_model") and args.push_model: +- mytenant.push_model = True +- else: +- mytenant.push_model = False + + if args.agent_uuid is not None: + mytenant.agent_uuid = args.agent_uuid +diff --git a/keylime/web/verifier_server.py b/keylime/web/verifier_server.py +index 11e2e5c..8d42a61 100755 +--- a/keylime/web/verifier_server.py ++++ b/keylime/web/verifier_server.py +@@ -183,7 +183,7 @@ class VerifierServer(Server): + def _setup(self) -> None: + self._set_component("verifier") + self._use_config("verifier") +- self._set_operating_mode(from_config="mode", fallback="pull") ++ self._set_operating_mode(value="pull") + self._set_bind_interface(from_config="ip") + self._set_http_port(value=None) # verifier does not accept insecure connections + self._set_https_port(from_config="port") +diff --git a/test/test_registrar_server.py b/test/test_registrar_server.py +index 17ee926..618f4d4 100644 +--- a/test/test_registrar_server.py ++++ b/test/test_registrar_server.py +@@ -123,25 +123,6 @@ class TestRegistrarServerV3Routes(unittest.TestCase): + self.assertFalse(route.requires_auth) + self.assertEqual(route.auth_action, Action.ACTIVATE_AGENT) + +- def test_v3_0_routes_also_match(self): +- """Test that all v3 routes also match with the /v3.0/ prefix.""" +- cases = [ +- ("get", "/v3.0/", VersionController, "show_version_root"), +- ("get", "/v3.0/agents", AgentsController, "index"), +- ("get", "/v3.0/agents/test-agent-id", AgentsController, "show"), +- ("delete", "/v3.0/agents/test-agent-id", AgentsController, "delete"), +- ("post", "/v3.0/agents", AgentsController, "create"), +- ("post", "/v3.0/agents/test-agent-id/activate", AgentsController, "activate"), +- ] +- for method, path, expected_controller, expected_action in cases: +- with self.subTest(method=method, path=path): +- route = self.server.first_matching_route(method, path) +- self.assertIsNotNone(route, f"No route found for {method.upper()} {path}") +- assert route is not None +- self.assertEqual(route.controller, expected_controller) +- self.assertEqual(route.action, expected_action) +- +- + class TestRegistrarServerV3NoCompatRoutes(unittest.TestCase): + """Test that v3 does NOT include the backwards-compatibility routes from v2. + +@@ -167,18 +148,6 @@ class TestRegistrarServerV3NoCompatRoutes(unittest.TestCase): + route = self.server.first_matching_route("put", "/v3/agents/some-agent-id") + self.assertIsNone(route) + +- def test_v3_0_no_compat_routes(self): +- """Test that v3.0 prefix also has no compat routes.""" +- cases = [ +- ("post", "/v3.0/agents/some-agent-id"), +- ("put", "/v3.0/agents/some-agent-id/activate"), +- ("put", "/v3.0/agents/some-agent-id"), +- ] +- for method, path in cases: +- with self.subTest(method=method, path=path): +- route = self.server.first_matching_route(method, path) +- self.assertIsNone(route, f"Unexpected compat route found for {method.upper()} {path}") +- + + class TestRegistrarServerV3MethodNotAllowed(unittest.TestCase): + """Test that legacy methods on v3 paths produce 405, not 404. +diff --git a/test/test_version_controller.py b/test/test_version_controller.py +index 8a78c2a..ffdca11 100644 +--- a/test/test_version_controller.py ++++ b/test/test_version_controller.py +@@ -34,12 +34,12 @@ class TestVersionControllerVersion(unittest.TestCase): + self.assertEqual(data["current_version"], keylime_api_version.current_version()) + self.assertEqual(data["supported_versions"], keylime_api_version.all_versions()) + +- def test_version_includes_v3(self): +- """Test that the supported versions list includes v3.0.""" ++ def test_version_does_not_include_v3(self): ++ """Test that v3.0 is not in supported versions (push model disabled).""" + self.controller.version() # pylint: disable=not-callable + + data = self.mock_respond.call_args[0][2] +- self.assertIn("3.0", data["supported_versions"]) ++ self.assertNotIn("3.0", data["supported_versions"]) + + + class TestVersionControllerShowVersionRoot(unittest.TestCase): +-- +2.54.0 + diff --git a/0004-templates-duplicate-str_to_version-in-the-adjust-scr.patch b/0004-templates-duplicate-str_to_version-in-the-adjust-scr.patch deleted file mode 100644 index 3432ee9..0000000 --- a/0004-templates-duplicate-str_to_version-in-the-adjust-scr.patch +++ /dev/null @@ -1,52 +0,0 @@ -From 7ca86e1c0d68f45915d9f583ffaf149285905005 Mon Sep 17 00:00:00 2001 -From: Sergio Correia -Date: Tue, 3 Jun 2025 10:50:48 +0100 -Subject: [PATCH 4/6] templates: duplicate str_to_version() in the adjust - script - -As a follow-up of upstream PR#1486, duplicate the str_to_version() -method in adjust.py so that we do not need the keylime modules in -order for the configuration upgrade script to run. - -Signed-off-by: Sergio Correia ---- - templates/2.0/adjust.py | 22 ++++++++++++++++++++-- - 1 file changed, 20 insertions(+), 2 deletions(-) - -diff --git a/templates/2.0/adjust.py b/templates/2.0/adjust.py -index 6008e4c..24ba898 100644 ---- a/templates/2.0/adjust.py -+++ b/templates/2.0/adjust.py -@@ -4,9 +4,27 @@ import logging - import re - from configparser import RawConfigParser - from logging import Logger --from typing import Dict, List, Optional, Tuple -+from typing import Dict, Tuple, Union - --from keylime.common.version import str_to_version -+ -+def str_to_version(v_str: str) -> Union[Tuple[int, int], None]: -+ """ -+ Validates the string format and converts the provided string to a tuple of -+ ints which can be sorted and compared. -+ -+ :returns: Tuple with version number parts converted to int. In case of -+ invalid version string, returns None -+ """ -+ -+ # Strip to remove eventual quotes and spaces -+ v_str = v_str.strip('" ') -+ -+ m = re.match(r"^(\d+)\.(\d+)$", v_str) -+ -+ if not m: -+ return None -+ -+ return (int(m.group(1)), int(m.group(2))) - - - def adjust( --- -2.47.1 - diff --git a/0005-fix-handle-invalid-config-values-for-typed-server-op.patch b/0005-fix-handle-invalid-config-values-for-typed-server-op.patch new file mode 100644 index 0000000..d4261ee --- /dev/null +++ b/0005-fix-handle-invalid-config-values-for-typed-server-op.patch @@ -0,0 +1,102 @@ +From e06745c7c2a28bf0c796f516f8fdb529176d630c Mon Sep 17 00:00:00 2001 +From: Anderson Toshiyuki Sasaki +Date: Thu, 16 Jul 2026 08:56:54 +0000 +Subject: [PATCH 5/5] fix: handle invalid config values for typed server + options + +When a typed configuration option (int, float, bool) contains an empty +or invalid value, configparser raises ValueError instead of using the +fallback. This causes the verifier to crash on startup when e.g. +max_workers has an empty value after a config upgrade. + +Catch ValueError in _set_option for typed config reads and fall back to +the caller-provided default with a warning. Also add a default fallback +of 0 for max_workers (meaning use all CPUs). + +Fixes: keylime/keylime#1929 + +Co-Authored-By: Claude Opus 4.6 (1M context) +Signed-off-by: Anderson Toshiyuki Sasaki +--- + keylime/web/base/server.py | 44 +++++++++++++++++++++++++++----------- + 1 file changed, 32 insertions(+), 12 deletions(-) + +diff --git a/keylime/web/base/server.py b/keylime/web/base/server.py +index 9ac3a3c..eee8313 100644 +--- a/keylime/web/base/server.py ++++ b/keylime/web/base/server.py +@@ -449,9 +449,34 @@ class Server(ABC): + """Sets config component (i.e., namespace) used to locate config values when setting server options.""" + self.__config_component = component + ++ _TYPED_CONFIG_GETTERS: dict[type, Callable[..., Any]] = { ++ int: config.getint, ++ float: config.getfloat, ++ bool: config.getboolean, ++ } ++ ++ def _get_typed_config_value(self, config_name: str, data_type: type, fallback: Any) -> Any: ++ """Read a typed config value, falling back gracefully on ValueError.""" ++ getter = self._TYPED_CONFIG_GETTERS[data_type] ++ try: ++ return getter(self.config_component, config_name, fallback=fallback) # type: ignore ++ except ValueError: ++ if fallback is None: ++ raise ++ raw_value = config.get(self.config_component, config_name, fallback="") # type: ignore[arg-type] ++ logger.warning( ++ "Cannot parse '%s' as %s for option '%s' (component: %s), using fallback: %s", ++ raw_value, ++ data_type.__name__, ++ config_name, ++ self.config_component, ++ fallback, ++ ) ++ return fallback ++ + def _set_option(self, name: str, **kwargs: Any) -> None: + """Sets server option by name either from given value or by obtaining it from user config. If the value is +- falsy, uses the given fallback value instead or ``None`` if no fallback is provided. Examples:: ++ ``None``, uses the given fallback value instead. Examples:: + + # Set option using provided value + self._set_option("bind_interface", value="0.0.0.0") +@@ -488,22 +513,14 @@ class Server(ABC): + config_name = __match_val["from_config"][0] + data_type = __match_val["from_config"][1] + value = config.get(self.config_component, config_name, fallback=fallback) # type: ignore +- elif (isinstance(__match_val, dict) and "from_config" in __match_val) and __match_val["from_config"][1] is int: +- config_name = __match_val["from_config"][0] +- data_type = __match_val["from_config"][1] +- value = config.getint(self.config_component, config_name, fallback=fallback) # type: ignore +- elif (isinstance(__match_val, dict) and "from_config" in __match_val) and __match_val["from_config"][1] is float: ++ elif (isinstance(__match_val, dict) and "from_config" in __match_val) and __match_val["from_config"][1] in self._TYPED_CONFIG_GETTERS: + config_name = __match_val["from_config"][0] + data_type = __match_val["from_config"][1] +- value = config.getfloat(self.config_component, config_name, fallback=fallback) # type: ignore +- elif (isinstance(__match_val, dict) and "from_config" in __match_val) and __match_val["from_config"][1] is bool: +- config_name = __match_val["from_config"][0] +- data_type = __match_val["from_config"][1] +- value = config.getboolean(self.config_component, config_name, fallback=fallback) # type: ignore ++ value = self._get_typed_config_value(config_name, data_type, fallback) + else: + raise TypeError(f"invalid arguments given when setting option '{name}' for {self.__class__.__name__}") + +- setattr(self, attr_name, value or fallback) ++ setattr(self, attr_name, value if value is not None else fallback) + + def _set_operating_mode(self, **kwargs: Any) -> None: + """Sets operating mode of the server (push or pull).""" +@@ -578,6 +595,9 @@ class Server(ABC): + if "from_config" in kwargs: + kwargs.update({"from_config": (kwargs["from_config"], int)}) + ++ if "fallback" not in kwargs: ++ kwargs.update({"fallback": 0}) ++ + self._set_option("max_workers", **kwargs) + + def _set_ssl_ctx(self, **kwargs: Any) -> None: +-- +2.54.0 + diff --git a/0007-fix_db_connection_leaks.patch b/0007-fix_db_connection_leaks.patch deleted file mode 100644 index 64be967..0000000 --- a/0007-fix_db_connection_leaks.patch +++ /dev/null @@ -1,2208 +0,0 @@ -diff --git a/keylime/cloud_verifier_tornado.py b/keylime/cloud_verifier_tornado.py -index 8ab81d1..7553ac8 100644 ---- a/keylime/cloud_verifier_tornado.py -+++ b/keylime/cloud_verifier_tornado.py -@@ -7,7 +7,8 @@ import sys - import traceback - from concurrent.futures import ThreadPoolExecutor - from multiprocessing import Process --from typing import Any, Dict, List, Optional, Tuple, Union, cast -+from contextlib import contextmanager -+from typing import Any, Dict, Iterator, List, Optional, Tuple, Union, cast - - import tornado.httpserver - import tornado.ioloop -@@ -34,7 +35,7 @@ from keylime.agentstates import AgentAttestState, AgentAttestStates - from keylime.common import retry, states, validators - from keylime.common.version import str_to_version - from keylime.da import record --from keylime.db.keylime_db import DBEngineManager, SessionManager -+from keylime.db.keylime_db import SessionManager, make_engine - from keylime.db.verifier_db import VerfierMain, VerifierAllowlist, VerifierMbpolicy - from keylime.failure import MAX_SEVERITY_LABEL, Component, Event, Failure, set_severity_config - from keylime.ima import ima -@@ -47,7 +48,7 @@ GLOBAL_POLICY_CACHE: Dict[str, Dict[str, str]] = {} - set_severity_config(config.getlist("verifier", "severity_labels"), config.getlist("verifier", "severity_policy")) - - try: -- engine = DBEngineManager().make_engine("cloud_verifier") -+ engine = make_engine("cloud_verifier") - except SQLAlchemyError as err: - logger.error("Error creating SQL engine or session: %s", err) - sys.exit(1) -@@ -61,8 +62,17 @@ except record.RecordManagementException as rme: - sys.exit(1) - - --def get_session() -> Session: -- return SessionManager().make_session(engine) -+@contextmanager -+def session_context() -> Iterator[Session]: -+ """ -+ Context manager for database sessions that ensures proper cleanup. -+ To use: -+ with session_context() as session: -+ # use session -+ """ -+ session_manager = SessionManager() -+ with session_manager.session_context(engine) as session: -+ yield session - - - def get_AgentAttestStates() -> AgentAttestStates: -@@ -130,19 +140,18 @@ def _from_db_obj(agent_db_obj: VerfierMain) -> Dict[str, Any]: - return agent_dict - - --def verifier_read_policy_from_cache(stored_agent: VerfierMain) -> str: -- checksum = "" -- name = "empty" -- agent_id = str(stored_agent.agent_id) -+def verifier_read_policy_from_cache(ima_policy_data: Dict[str, str]) -> str: -+ checksum = ima_policy_data.get("checksum", "") -+ name = ima_policy_data.get("name", "empty") -+ agent_id = ima_policy_data.get("agent_id", "") -+ -+ if not agent_id: -+ return "" - - if agent_id not in GLOBAL_POLICY_CACHE: - GLOBAL_POLICY_CACHE[agent_id] = {} - GLOBAL_POLICY_CACHE[agent_id][""] = "" - -- if stored_agent.ima_policy: -- checksum = str(stored_agent.ima_policy.checksum) -- name = stored_agent.ima_policy.name -- - if checksum not in GLOBAL_POLICY_CACHE[agent_id]: - if len(GLOBAL_POLICY_CACHE[agent_id]) > 1: - # Perform a cleanup of the contents, IMA policy checksum changed -@@ -162,8 +171,9 @@ def verifier_read_policy_from_cache(stored_agent: VerfierMain) -> str: - checksum, - agent_id, - ) -- # Actually contacts the database and load the (large) ima_policy column for "allowlists" table -- ima_policy = stored_agent.ima_policy.ima_policy -+ -+ # Get the large ima_policy content - it's already loaded in ima_policy_data -+ ima_policy = ima_policy_data.get("ima_policy", "") - assert isinstance(ima_policy, str) - GLOBAL_POLICY_CACHE[agent_id][checksum] = ima_policy - -@@ -182,22 +192,19 @@ def store_attestation_state(agentAttestState: AgentAttestState) -> None: - # Only store if IMA log was evaluated - if agentAttestState.get_ima_pcrs(): - agent_id = agentAttestState.agent_id -- session = get_session() - try: -- update_agent = session.query(VerfierMain).get(agentAttestState.get_agent_id()) -- assert update_agent -- update_agent.boottime = agentAttestState.get_boottime() -- update_agent.next_ima_ml_entry = agentAttestState.get_next_ima_ml_entry() -- ima_pcrs_dict = agentAttestState.get_ima_pcrs() -- update_agent.ima_pcrs = list(ima_pcrs_dict.keys()) -- for pcr_num, value in ima_pcrs_dict.items(): -- setattr(update_agent, f"pcr{pcr_num}", value) -- update_agent.learned_ima_keyrings = agentAttestState.get_ima_keyrings().to_json() -- try: -+ with session_context() as session: -+ update_agent = session.query(VerfierMain).get(agentAttestState.get_agent_id()) -+ assert update_agent -+ update_agent.boottime = agentAttestState.get_boottime() -+ update_agent.next_ima_ml_entry = agentAttestState.get_next_ima_ml_entry() -+ ima_pcrs_dict = agentAttestState.get_ima_pcrs() -+ update_agent.ima_pcrs = list(ima_pcrs_dict.keys()) -+ for pcr_num, value in ima_pcrs_dict.items(): -+ setattr(update_agent, f"pcr{pcr_num}", value) -+ update_agent.learned_ima_keyrings = agentAttestState.get_ima_keyrings().to_json() - session.add(update_agent) -- except SQLAlchemyError as e: -- logger.error("SQLAlchemy Error on storing attestation state for agent %s: %s", agent_id, e) -- session.commit() -+ # session.commit() is automatically called by context manager - except SQLAlchemyError as e: - logger.error("SQLAlchemy Error on storing attestation state for agent %s: %s", agent_id, e) - -@@ -354,45 +361,17 @@ class AgentsHandler(BaseHandler): - was not found, it either completed successfully, or failed. If found, the agent_id is still polling - to contact the Cloud Agent. - """ -- session = get_session() -- - rest_params, agent_id = self.__validate_input("GET") - if not rest_params: - return - -- if (agent_id is not None) and (agent_id != ""): -- # If the agent ID is not valid (wrong set of characters), -- # just do nothing. -- agent = None -- try: -- agent = ( -- session.query(VerfierMain) -- .options( # type: ignore -- joinedload(VerfierMain.ima_policy).load_only( -- VerifierAllowlist.checksum, VerifierAllowlist.generator # pyright: ignore -- ) -- ) -- .options( # type: ignore -- joinedload(VerfierMain.mb_policy).load_only(VerifierMbpolicy.mb_policy) # pyright: ignore -- ) -- .filter_by(agent_id=agent_id) -- .one_or_none() -- ) -- except SQLAlchemyError as e: -- logger.error("SQLAlchemy Error for agent ID %s: %s", agent_id, e) -- -- if agent is not None: -- response = cloud_verifier_common.process_get_status(agent) -- web_util.echo_json_response(self, 200, "Success", response) -- else: -- web_util.echo_json_response(self, 404, "agent id not found") -- else: -- json_response = None -- if "bulk" in rest_params: -- agent_list = None -- -- if ("verifier" in rest_params) and (rest_params["verifier"] != ""): -- agent_list = ( -+ with session_context() as session: -+ if (agent_id is not None) and (agent_id != ""): -+ # If the agent ID is not valid (wrong set of characters), -+ # just do nothing. -+ agent = None -+ try: -+ agent = ( - session.query(VerfierMain) - .options( # type: ignore - joinedload(VerfierMain.ima_policy).load_only( -@@ -402,39 +381,70 @@ class AgentsHandler(BaseHandler): - .options( # type: ignore - joinedload(VerfierMain.mb_policy).load_only(VerifierMbpolicy.mb_policy) # pyright: ignore - ) -- .filter_by(verifier_id=rest_params["verifier"]) -- .all() -+ .filter_by(agent_id=agent_id) -+ .one_or_none() - ) -+ except SQLAlchemyError as e: -+ logger.error("SQLAlchemy Error for agent ID %s: %s", agent_id, e) -+ -+ if agent is not None: -+ response = cloud_verifier_common.process_get_status(agent) -+ web_util.echo_json_response(self, 200, "Success", response) - else: -- agent_list = ( -- session.query(VerfierMain) -- .options( # type: ignore -- joinedload(VerfierMain.ima_policy).load_only( -- VerifierAllowlist.checksum, VerifierAllowlist.generator # pyright: ignore -+ web_util.echo_json_response(self, 404, "agent id not found") -+ else: -+ json_response = None -+ if "bulk" in rest_params: -+ agent_list = None -+ -+ if ("verifier" in rest_params) and (rest_params["verifier"] != ""): -+ agent_list = ( -+ session.query(VerfierMain) -+ .options( # type: ignore -+ joinedload(VerfierMain.ima_policy).load_only( -+ VerifierAllowlist.checksum, VerifierAllowlist.generator # pyright: ignore -+ ) - ) -+ .options( # type: ignore -+ joinedload(VerfierMain.mb_policy).load_only( -+ VerifierMbpolicy.mb_policy # type: ignore[arg-type] -+ ) -+ ) -+ .filter_by(verifier_id=rest_params["verifier"]) -+ .all() - ) -- .options( # type: ignore -- joinedload(VerfierMain.mb_policy).load_only(VerifierMbpolicy.mb_policy) # pyright: ignore -+ else: -+ agent_list = ( -+ session.query(VerfierMain) -+ .options( # type: ignore -+ joinedload(VerfierMain.ima_policy).load_only( -+ VerifierAllowlist.checksum, VerifierAllowlist.generator # pyright: ignore -+ ) -+ ) -+ .options( # type: ignore -+ joinedload(VerfierMain.mb_policy).load_only( -+ VerifierMbpolicy.mb_policy # type: ignore[arg-type] -+ ) -+ ) -+ .all() - ) -- .all() -- ) - -- json_response = {} -- for agent in agent_list: -- json_response[agent.agent_id] = cloud_verifier_common.process_get_status(agent) -+ json_response = {} -+ for agent in agent_list: -+ json_response[agent.agent_id] = cloud_verifier_common.process_get_status(agent) - -- web_util.echo_json_response(self, 200, "Success", json_response) -- else: -- if ("verifier" in rest_params) and (rest_params["verifier"] != ""): -- json_response_list = ( -- session.query(VerfierMain.agent_id).filter_by(verifier_id=rest_params["verifier"]).all() -- ) -+ web_util.echo_json_response(self, 200, "Success", json_response) - else: -- json_response_list = session.query(VerfierMain.agent_id).all() -+ if ("verifier" in rest_params) and (rest_params["verifier"] != ""): -+ json_response_list = ( -+ session.query(VerfierMain.agent_id).filter_by(verifier_id=rest_params["verifier"]).all() -+ ) -+ else: -+ json_response_list = session.query(VerfierMain.agent_id).all() - -- web_util.echo_json_response(self, 200, "Success", {"uuids": json_response_list}) -+ web_util.echo_json_response(self, 200, "Success", {"uuids": json_response_list}) - -- logger.info("GET returning 200 response for agent_id list") -+ logger.info("GET returning 200 response for agent_id list") - - def delete(self) -> None: - """This method handles the DELETE requests to remove agents from the Cloud Verifier. -@@ -442,59 +452,55 @@ class AgentsHandler(BaseHandler): - Currently, only agents resources are available for DELETEing, i.e. /agents. All other DELETE uri's will return errors. - agents requests require a single agent_id parameter which identifies the agent to be deleted. - """ -- session = get_session() -- - rest_params, agent_id = self.__validate_input("DELETE") - if not rest_params or not agent_id: - return - -- agent = None -- try: -- agent = session.query(VerfierMain).filter_by(agent_id=agent_id).first() -- except SQLAlchemyError as e: -- logger.error("SQLAlchemy Error for agent ID %s: %s", agent_id, e) -+ with session_context() as session: -+ agent = None -+ try: -+ agent = session.query(VerfierMain).filter_by(agent_id=agent_id).first() -+ except SQLAlchemyError as e: -+ logger.error("SQLAlchemy Error for agent ID %s: %s", agent_id, e) - -- if agent is None: -- web_util.echo_json_response(self, 404, "agent id not found") -- logger.info("DELETE returning 404 response. agent id: %s not found.", agent_id) -- return -+ if agent is None: -+ web_util.echo_json_response(self, 404, "agent id not found") -+ logger.info("DELETE returning 404 response. agent id: %s not found.", agent_id) -+ return - -- verifier_id = config.get("verifier", "uuid", fallback=cloud_verifier_common.DEFAULT_VERIFIER_ID) -- if verifier_id != agent.verifier_id: -- web_util.echo_json_response(self, 404, "agent id associated to this verifier") -- logger.info("DELETE returning 404 response. agent id: %s not associated to this verifer.", agent_id) -- return -+ verifier_id = config.get("verifier", "uuid", fallback=cloud_verifier_common.DEFAULT_VERIFIER_ID) -+ if verifier_id != agent.verifier_id: -+ web_util.echo_json_response(self, 404, "agent id associated to this verifier") -+ logger.info("DELETE returning 404 response. agent id: %s not associated to this verifer.", agent_id) -+ return - -- # Cleanup the cache when the agent is deleted. Do it early. -- if agent_id in GLOBAL_POLICY_CACHE: -- del GLOBAL_POLICY_CACHE[agent_id] -- logger.debug( -- "Cleaned up policy cache from all entries used by agent %s", -- agent_id, -- ) -+ # Cleanup the cache when the agent is deleted. Do it early. -+ if agent_id in GLOBAL_POLICY_CACHE: -+ del GLOBAL_POLICY_CACHE[agent_id] -+ logger.debug( -+ "Cleaned up policy cache from all entries used by agent %s", -+ agent_id, -+ ) - -- op_state = agent.operational_state -- if op_state in (states.SAVED, states.FAILED, states.TERMINATED, states.TENANT_FAILED, states.INVALID_QUOTE): -- try: -- verifier_db_delete_agent(session, agent_id) -- except SQLAlchemyError as e: -- logger.error("SQLAlchemy Error: %s", e) -- web_util.echo_json_response(self, 200, "Success") -- logger.info("DELETE returning 200 response for agent id: %s", agent_id) -- else: -- try: -- update_agent = session.query(VerfierMain).get(agent_id) -- assert update_agent -- update_agent.operational_state = states.TERMINATED -+ op_state = agent.operational_state -+ if op_state in (states.SAVED, states.FAILED, states.TERMINATED, states.TENANT_FAILED, states.INVALID_QUOTE): - try: -+ verifier_db_delete_agent(session, agent_id) -+ except SQLAlchemyError as e: -+ logger.error("SQLAlchemy Error: %s", e) -+ web_util.echo_json_response(self, 200, "Success") -+ logger.info("DELETE returning 200 response for agent id: %s", agent_id) -+ else: -+ try: -+ update_agent = session.query(VerfierMain).get(agent_id) -+ assert update_agent -+ update_agent.operational_state = states.TERMINATED - session.add(update_agent) -+ # session.commit() is automatically called by context manager -+ web_util.echo_json_response(self, 202, "Accepted") -+ logger.info("DELETE returning 202 response for agent id: %s", agent_id) - except SQLAlchemyError as e: - logger.error("SQLAlchemy Error for agent ID %s: %s", agent_id, e) -- session.commit() -- web_util.echo_json_response(self, 202, "Accepted") -- logger.info("DELETE returning 202 response for agent id: %s", agent_id) -- except SQLAlchemyError as e: -- logger.error("SQLAlchemy Error for agent ID %s: %s", agent_id, e) - - def post(self) -> None: - """This method handles the POST requests to add agents to the Cloud Verifier. -@@ -502,7 +508,6 @@ class AgentsHandler(BaseHandler): - Currently, only agents resources are available for POSTing, i.e. /agents. All other POST uri's will return errors. - agents requests require a json block sent in the body - """ -- session = get_session() - # TODO: exception handling needs fixing - # Maybe handle exceptions with if/else if/else blocks ... simple and avoids nesting - try: # pylint: disable=too-many-nested-blocks -@@ -585,201 +590,208 @@ class AgentsHandler(BaseHandler): - runtime_policy = base64.b64decode(json_body.get("runtime_policy")).decode() - runtime_policy_stored = None - -- if runtime_policy_name: -+ with session_context() as session: -+ if runtime_policy_name: -+ try: -+ runtime_policy_stored = ( -+ session.query(VerifierAllowlist).filter_by(name=runtime_policy_name).one_or_none() -+ ) -+ except SQLAlchemyError as e: -+ logger.error("SQLAlchemy Error for agent ID %s: %s", agent_id, e) -+ raise -+ -+ # Prevent overwriting existing IMA policies with name provided in request -+ if runtime_policy and runtime_policy_stored: -+ web_util.echo_json_response( -+ self, -+ 409, -+ f"IMA policy with name {runtime_policy_name} already exists. Please use a different name or delete the allowlist from the verifier.", -+ ) -+ logger.warning("IMA policy with name %s already exists", runtime_policy_name) -+ return -+ -+ # Return an error code if the named allowlist does not exist in the database -+ if not runtime_policy and not runtime_policy_stored: -+ web_util.echo_json_response( -+ self, 404, f"Could not find IMA policy with name {runtime_policy_name}!" -+ ) -+ logger.warning("Could not find IMA policy with name %s", runtime_policy_name) -+ return -+ -+ # Prevent overwriting existing agents with UUID provided in request - try: -- runtime_policy_stored = ( -- session.query(VerifierAllowlist).filter_by(name=runtime_policy_name).one_or_none() -- ) -+ new_agent_count = session.query(VerfierMain).filter_by(agent_id=agent_id).count() - except SQLAlchemyError as e: - logger.error("SQLAlchemy Error for agent ID %s: %s", agent_id, e) -- raise -+ raise e - -- # Prevent overwriting existing IMA policies with name provided in request -- if runtime_policy and runtime_policy_stored: -+ if new_agent_count > 0: - web_util.echo_json_response( - self, - 409, -- f"IMA policy with name {runtime_policy_name} already exists. Please use a different name or delete the allowlist from the verifier.", -+ f"Agent of uuid {agent_id} already exists. Please use delete or update.", - ) -- logger.warning("IMA policy with name %s already exists", runtime_policy_name) -+ logger.warning("Agent of uuid %s already exists", agent_id) - return - -- # Return an error code if the named allowlist does not exist in the database -- if not runtime_policy and not runtime_policy_stored: -- web_util.echo_json_response( -- self, 404, f"Could not find IMA policy with name {runtime_policy_name}!" -- ) -- logger.warning("Could not find IMA policy with name %s", runtime_policy_name) -- return -- -- # Prevent overwriting existing agents with UUID provided in request -- try: -- new_agent_count = session.query(VerfierMain).filter_by(agent_id=agent_id).count() -- except SQLAlchemyError as e: -- logger.error("SQLAlchemy Error for agent ID %s: %s", agent_id, e) -- raise e -- -- if new_agent_count > 0: -- web_util.echo_json_response( -- self, -- 409, -- f"Agent of uuid {agent_id} already exists. Please use delete or update.", -- ) -- logger.warning("Agent of uuid %s already exists", agent_id) -- return -- -- # Write IMA policy to database if needed -- if not runtime_policy_name and not runtime_policy: -- logger.info("IMA policy data not provided with request! Using default empty IMA policy.") -- runtime_policy = json.dumps(cast(Dict[str, Any], ima.EMPTY_RUNTIME_POLICY)) -+ # Write IMA policy to database if needed -+ if not runtime_policy_name and not runtime_policy: -+ logger.info("IMA policy data not provided with request! Using default empty IMA policy.") -+ runtime_policy = json.dumps(cast(Dict[str, Any], ima.EMPTY_RUNTIME_POLICY)) - -- if runtime_policy: -- runtime_policy_key_bytes = signing.get_runtime_policy_keys( -- runtime_policy.encode(), -- json_body.get("runtime_policy_key"), -- ) -- -- try: -- ima.verify_runtime_policy( -+ if runtime_policy: -+ runtime_policy_key_bytes = signing.get_runtime_policy_keys( - runtime_policy.encode(), -- runtime_policy_key_bytes, -- verify_sig=config.getboolean( -- "verifier", "require_allow_list_signatures", fallback=False -- ), -+ json_body.get("runtime_policy_key"), - ) -- except ima.ImaValidationError as e: -- web_util.echo_json_response(self, e.code, e.message) -- logger.warning(e.message) -- return - -- if not runtime_policy_name: -- runtime_policy_name = agent_id -- -- try: -- runtime_policy_db_format = ima.runtime_policy_db_contents( -- runtime_policy_name, runtime_policy -- ) -- except ima.ImaValidationError as e: -- message = f"Runtime policy is malformatted: {e.message}" -- web_util.echo_json_response(self, e.code, message) -- logger.warning(message) -- return -- -- try: -- runtime_policy_stored = ( -- session.query(VerifierAllowlist).filter_by(name=runtime_policy_name).one_or_none() -- ) -- except SQLAlchemyError as e: -- logger.error( -- "SQLAlchemy Error while retrieving stored ima policy for agent ID %s: %s", agent_id, e -- ) -- raise -- try: -- if runtime_policy_stored is None: -- runtime_policy_stored = VerifierAllowlist(**runtime_policy_db_format) -- session.add(runtime_policy_stored) -+ try: -+ ima.verify_runtime_policy( -+ runtime_policy.encode(), -+ runtime_policy_key_bytes, -+ verify_sig=config.getboolean( -+ "verifier", "require_allow_list_signatures", fallback=False -+ ), -+ ) -+ except ima.ImaValidationError as e: -+ web_util.echo_json_response(self, e.code, e.message) -+ logger.warning(e.message) -+ return -+ -+ if not runtime_policy_name: -+ runtime_policy_name = agent_id -+ -+ try: -+ runtime_policy_db_format = ima.runtime_policy_db_contents( -+ runtime_policy_name, runtime_policy -+ ) -+ except ima.ImaValidationError as e: -+ message = f"Runtime policy is malformatted: {e.message}" -+ web_util.echo_json_response(self, e.code, message) -+ logger.warning(message) -+ return -+ -+ try: -+ runtime_policy_stored = ( -+ session.query(VerifierAllowlist).filter_by(name=runtime_policy_name).one_or_none() -+ ) -+ except SQLAlchemyError as e: -+ logger.error( -+ "SQLAlchemy Error while retrieving stored ima policy for agent ID %s: %s", -+ agent_id, -+ e, -+ ) -+ raise -+ try: -+ if runtime_policy_stored is None: -+ runtime_policy_stored = VerifierAllowlist(**runtime_policy_db_format) -+ session.add(runtime_policy_stored) -+ session.commit() -+ except SQLAlchemyError as e: -+ logger.error( -+ "SQLAlchemy Error while updating ima policy for agent ID %s: %s", agent_id, e -+ ) -+ raise -+ -+ # Handle measured boot policy -+ # - No name, mb_policy : store mb_policy using agent UUID as name -+ # - Name, no mb_policy : fetch existing mb_policy from DB -+ # - Name, mb_policy : store mb_policy using name -+ -+ mb_policy_name = json_body["mb_policy_name"] -+ mb_policy = json_body["mb_policy"] -+ mb_policy_stored = None -+ -+ if mb_policy_name: -+ try: -+ mb_policy_stored = ( -+ session.query(VerifierMbpolicy).filter_by(name=mb_policy_name).one_or_none() -+ ) -+ except SQLAlchemyError as e: -+ logger.error("SQLAlchemy Error for agent ID %s: %s", agent_id, e) -+ raise -+ -+ # Prevent overwriting existing mb_policy with name provided in request -+ if mb_policy and mb_policy_stored: -+ web_util.echo_json_response( -+ self, -+ 409, -+ f"mb_policy with name {mb_policy_name} already exists. Please use a different name or delete the mb_policy from the verifier.", -+ ) -+ logger.warning("mb_policy with name %s already exists", mb_policy_name) -+ return -+ -+ # Return error if the mb_policy is neither provided nor stored. -+ if not mb_policy and not mb_policy_stored: -+ web_util.echo_json_response( -+ self, 404, f"Could not find mb_policy with name {mb_policy_name}!" -+ ) -+ logger.warning("Could not find mb_policy with name %s", mb_policy_name) -+ return -+ -+ else: -+ # Use the UUID of the agent -+ mb_policy_name = agent_id -+ try: -+ mb_policy_stored = ( -+ session.query(VerifierMbpolicy).filter_by(name=mb_policy_name).one_or_none() -+ ) -+ except SQLAlchemyError as e: -+ logger.error("SQLAlchemy Error for agent ID %s: %s", agent_id, e) -+ raise -+ -+ # Prevent overwriting existing mb_policy -+ if mb_policy and mb_policy_stored: -+ web_util.echo_json_response( -+ self, -+ 409, -+ f"mb_policy with name {mb_policy_name} already exists. You can delete the mb_policy from the verifier.", -+ ) -+ logger.warning("mb_policy with name %s already exists", mb_policy_name) -+ return -+ -+ # Store the policy into database if not stored -+ if mb_policy_stored is None: -+ try: -+ mb_policy_db_format = mba.mb_policy_db_contents(mb_policy_name, mb_policy) -+ mb_policy_stored = VerifierMbpolicy(**mb_policy_db_format) -+ session.add(mb_policy_stored) - session.commit() -- except SQLAlchemyError as e: -- logger.error("SQLAlchemy Error while updating ima policy for agent ID %s: %s", agent_id, e) -- raise -- -- # Handle measured boot policy -- # - No name, mb_policy : store mb_policy using agent UUID as name -- # - Name, no mb_policy : fetch existing mb_policy from DB -- # - Name, mb_policy : store mb_policy using name -- -- mb_policy_name = json_body["mb_policy_name"] -- mb_policy = json_body["mb_policy"] -- mb_policy_stored = None -+ except SQLAlchemyError as e: -+ logger.error( -+ "SQLAlchemy Error while updating mb_policy for agent ID %s: %s", agent_id, e -+ ) -+ raise - -- if mb_policy_name: -+ # Write the agent to the database, attaching associated stored ima_policy and mb_policy - try: -- mb_policy_stored = ( -- session.query(VerifierMbpolicy).filter_by(name=mb_policy_name).one_or_none() -+ assert runtime_policy_stored -+ assert mb_policy_stored -+ session.add( -+ VerfierMain(**agent_data, ima_policy=runtime_policy_stored, mb_policy=mb_policy_stored) - ) -+ session.commit() - except SQLAlchemyError as e: - logger.error("SQLAlchemy Error for agent ID %s: %s", agent_id, e) -- raise -+ raise e - -- # Prevent overwriting existing mb_policy with name provided in request -- if mb_policy and mb_policy_stored: -- web_util.echo_json_response( -- self, -- 409, -- f"mb_policy with name {mb_policy_name} already exists. Please use a different name or delete the mb_policy from the verifier.", -- ) -- logger.warning("mb_policy with name %s already exists", mb_policy_name) -- return -+ # add default fields that are ephemeral -+ for key, val in exclude_db.items(): -+ agent_data[key] = val - -- # Return error if the mb_policy is neither provided nor stored. -- if not mb_policy and not mb_policy_stored: -- web_util.echo_json_response( -- self, 404, f"Could not find mb_policy with name {mb_policy_name}!" -+ # Prepare SSLContext for mTLS connections -+ agent_data["ssl_context"] = None -+ if agent_mtls_cert_enabled: -+ agent_data["ssl_context"] = web_util.generate_agent_tls_context( -+ "verifier", agent_data["mtls_cert"], logger=logger - ) -- logger.warning("Could not find mb_policy with name %s", mb_policy_name) -- return - -- else: -- # Use the UUID of the agent -- mb_policy_name = agent_id -- try: -- mb_policy_stored = ( -- session.query(VerifierMbpolicy).filter_by(name=mb_policy_name).one_or_none() -- ) -- except SQLAlchemyError as e: -- logger.error("SQLAlchemy Error for agent ID %s: %s", agent_id, e) -- raise -- -- # Prevent overwriting existing mb_policy -- if mb_policy and mb_policy_stored: -- web_util.echo_json_response( -- self, -- 409, -- f"mb_policy with name {mb_policy_name} already exists. You can delete the mb_policy from the verifier.", -- ) -- logger.warning("mb_policy with name %s already exists", mb_policy_name) -- return -- -- # Store the policy into database if not stored -- if mb_policy_stored is None: -- try: -- mb_policy_db_format = mba.mb_policy_db_contents(mb_policy_name, mb_policy) -- mb_policy_stored = VerifierMbpolicy(**mb_policy_db_format) -- session.add(mb_policy_stored) -- session.commit() -- except SQLAlchemyError as e: -- logger.error("SQLAlchemy Error while updating mb_policy for agent ID %s: %s", agent_id, e) -- raise -+ if agent_data["ssl_context"] is None: -+ logger.warning("Connecting to agent without mTLS: %s", agent_id) - -- # Write the agent to the database, attaching associated stored ima_policy and mb_policy -- try: -- assert runtime_policy_stored -- assert mb_policy_stored -- session.add( -- VerfierMain(**agent_data, ima_policy=runtime_policy_stored, mb_policy=mb_policy_stored) -- ) -- session.commit() -- except SQLAlchemyError as e: -- logger.error("SQLAlchemy Error for agent ID %s: %s", agent_id, e) -- raise e -- -- # add default fields that are ephemeral -- for key, val in exclude_db.items(): -- agent_data[key] = val -- -- # Prepare SSLContext for mTLS connections -- agent_data["ssl_context"] = None -- if agent_mtls_cert_enabled: -- agent_data["ssl_context"] = web_util.generate_agent_tls_context( -- "verifier", agent_data["mtls_cert"], logger=logger -- ) -- -- if agent_data["ssl_context"] is None: -- logger.warning("Connecting to agent without mTLS: %s", agent_id) -- -- asyncio.ensure_future(process_agent(agent_data, states.GET_QUOTE)) -- web_util.echo_json_response(self, 200, "Success") -- logger.info("POST returning 200 response for adding agent id: %s", agent_id) -+ asyncio.ensure_future(process_agent(agent_data, states.GET_QUOTE)) -+ web_util.echo_json_response(self, 200, "Success") -+ logger.info("POST returning 200 response for adding agent id: %s", agent_id) - else: - web_util.echo_json_response(self, 400, "uri not supported") - logger.warning("POST returning 400 response. uri not supported") -@@ -794,54 +806,54 @@ class AgentsHandler(BaseHandler): - Currently, only agents resources are available for PUTing, i.e. /agents. All other PUT uri's will return errors. - agents requests require a json block sent in the body - """ -- session = get_session() - try: - rest_params, agent_id = self.__validate_input("PUT") - if not rest_params: - return - -- try: -- verifier_id = config.get("verifier", "uuid", fallback=cloud_verifier_common.DEFAULT_VERIFIER_ID) -- db_agent = session.query(VerfierMain).filter_by(agent_id=agent_id, verifier_id=verifier_id).one() -- except SQLAlchemyError as e: -- logger.error("SQLAlchemy Error for agent ID %s: %s", agent_id, e) -- raise e -+ with session_context() as session: -+ try: -+ verifier_id = config.get("verifier", "uuid", fallback=cloud_verifier_common.DEFAULT_VERIFIER_ID) -+ db_agent = session.query(VerfierMain).filter_by(agent_id=agent_id, verifier_id=verifier_id).one() -+ except SQLAlchemyError as e: -+ logger.error("SQLAlchemy Error for agent ID %s: %s", agent_id, e) -+ raise e - -- if db_agent is None: -- web_util.echo_json_response(self, 404, "agent id not found") -- logger.info("PUT returning 404 response. agent id: %s not found.", agent_id) -- return -+ if db_agent is None: -+ web_util.echo_json_response(self, 404, "agent id not found") -+ logger.info("PUT returning 404 response. agent id: %s not found.", agent_id) -+ return - -- if "reactivate" in rest_params: -- agent = _from_db_obj(db_agent) -+ if "reactivate" in rest_params: -+ agent = _from_db_obj(db_agent) - -- if agent["mtls_cert"] and agent["mtls_cert"] != "disabled": -- agent["ssl_context"] = web_util.generate_agent_tls_context( -- "verifier", agent["mtls_cert"], logger=logger -- ) -- if agent["ssl_context"] is None: -- logger.warning("Connecting to agent without mTLS: %s", agent_id) -+ if agent["mtls_cert"] and agent["mtls_cert"] != "disabled": -+ agent["ssl_context"] = web_util.generate_agent_tls_context( -+ "verifier", agent["mtls_cert"], logger=logger -+ ) -+ if agent["ssl_context"] is None: -+ logger.warning("Connecting to agent without mTLS: %s", agent_id) - -- agent["operational_state"] = states.START -- asyncio.ensure_future(process_agent(agent, states.GET_QUOTE)) -- web_util.echo_json_response(self, 200, "Success") -- logger.info("PUT returning 200 response for agent id: %s", agent_id) -- elif "stop" in rest_params: -- # do stuff for terminate -- logger.debug("Stopping polling on %s", agent_id) -- try: -- session.query(VerfierMain).filter(VerfierMain.agent_id == agent_id).update( # pyright: ignore -- {"operational_state": states.TENANT_FAILED} -- ) -- session.commit() -- except SQLAlchemyError as e: -- logger.error("SQLAlchemy Error: %s", e) -+ agent["operational_state"] = states.START -+ asyncio.ensure_future(process_agent(agent, states.GET_QUOTE)) -+ web_util.echo_json_response(self, 200, "Success") -+ logger.info("PUT returning 200 response for agent id: %s", agent_id) -+ elif "stop" in rest_params: -+ # do stuff for terminate -+ logger.debug("Stopping polling on %s", agent_id) -+ try: -+ session.query(VerfierMain).filter(VerfierMain.agent_id == agent_id).update( # pyright: ignore -+ {"operational_state": states.TENANT_FAILED} -+ ) -+ # session.commit() is automatically called by context manager -+ except SQLAlchemyError as e: -+ logger.error("SQLAlchemy Error: %s", e) - -- web_util.echo_json_response(self, 200, "Success") -- logger.info("PUT returning 200 response for agent id: %s", agent_id) -- else: -- web_util.echo_json_response(self, 400, "uri not supported") -- logger.warning("PUT returning 400 response. uri not supported") -+ web_util.echo_json_response(self, 200, "Success") -+ logger.info("PUT returning 200 response for agent id: %s", agent_id) -+ else: -+ web_util.echo_json_response(self, 400, "uri not supported") -+ logger.warning("PUT returning 400 response. uri not supported") - - except Exception as e: - web_util.echo_json_response(self, 400, f"Exception error: {str(e)}") -@@ -887,36 +899,36 @@ class AllowlistHandler(BaseHandler): - if not params_valid: - return - -- session = get_session() -- if allowlist_name is None: -- try: -- names_allowlists = session.query(VerifierAllowlist.name).all() -- except SQLAlchemyError as e: -- logger.error("SQLAlchemy Error: %s", e) -- web_util.echo_json_response(self, 500, "Failed to get names of allowlists") -- raise -+ with session_context() as session: -+ if allowlist_name is None: -+ try: -+ names_allowlists = session.query(VerifierAllowlist.name).all() -+ except SQLAlchemyError as e: -+ logger.error("SQLAlchemy Error: %s", e) -+ web_util.echo_json_response(self, 500, "Failed to get names of allowlists") -+ raise - -- names_response = [] -- for name in names_allowlists: -- names_response.append(name[0]) -- web_util.echo_json_response(self, 200, "Success", {"runtimepolicy names": names_response}) -+ names_response = [] -+ for name in names_allowlists: -+ names_response.append(name[0]) -+ web_util.echo_json_response(self, 200, "Success", {"runtimepolicy names": names_response}) - -- else: -- try: -- allowlist = session.query(VerifierAllowlist).filter_by(name=allowlist_name).one() -- except NoResultFound: -- web_util.echo_json_response(self, 404, f"Runtime policy {allowlist_name} not found") -- return -- except SQLAlchemyError as e: -- logger.error("SQLAlchemy Error: %s", e) -- web_util.echo_json_response(self, 500, "Failed to get allowlist") -- raise -+ else: -+ try: -+ allowlist = session.query(VerifierAllowlist).filter_by(name=allowlist_name).one() -+ except NoResultFound: -+ web_util.echo_json_response(self, 404, f"Runtime policy {allowlist_name} not found") -+ return -+ except SQLAlchemyError as e: -+ logger.error("SQLAlchemy Error: %s", e) -+ web_util.echo_json_response(self, 500, "Failed to get allowlist") -+ raise - -- response = {} -- for field in ("name", "tpm_policy"): -- response[field] = getattr(allowlist, field, None) -- response["runtime_policy"] = getattr(allowlist, "ima_policy", None) -- web_util.echo_json_response(self, 200, "Success", response) -+ response = {} -+ for field in ("name", "tmp_policy"): -+ response[field] = getattr(allowlist, field, None) -+ response["runtime_policy"] = getattr(allowlist, "ima_policy", None) -+ web_util.echo_json_response(self, 200, "Success", response) - - def delete(self) -> None: - """Delete an allowlist -@@ -928,45 +940,44 @@ class AllowlistHandler(BaseHandler): - if not params_valid or allowlist_name is None: - return - -- session = get_session() -- try: -- runtime_policy = session.query(VerifierAllowlist).filter_by(name=allowlist_name).one() -- except NoResultFound: -- web_util.echo_json_response(self, 404, f"Runtime policy {allowlist_name} not found") -- return -- except SQLAlchemyError as e: -- logger.error("SQLAlchemy Error: %s", e) -- web_util.echo_json_response(self, 500, "Failed to get allowlist") -- raise -+ with session_context() as session: -+ try: -+ runtime_policy = session.query(VerifierAllowlist).filter_by(name=allowlist_name).one() -+ except NoResultFound: -+ web_util.echo_json_response(self, 404, f"Runtime policy {allowlist_name} not found") -+ return -+ except SQLAlchemyError as e: -+ logger.error("SQLAlchemy Error: %s", e) -+ web_util.echo_json_response(self, 500, "Failed to get allowlist") -+ raise - -- try: -- agent = session.query(VerfierMain).filter_by(ima_policy_id=runtime_policy.id).one_or_none() -- except SQLAlchemyError as e: -- logger.error("SQLAlchemy Error: %s", e) -- raise -- if agent is not None: -- web_util.echo_json_response( -- self, -- 409, -- f"Can't delete allowlist as it's currently in use by agent {agent.agent_id}", -- ) -- return -+ try: -+ agent = session.query(VerfierMain).filter_by(ima_policy_id=runtime_policy.id).one_or_none() -+ except SQLAlchemyError as e: -+ logger.error("SQLAlchemy Error: %s", e) -+ raise -+ if agent is not None: -+ web_util.echo_json_response( -+ self, -+ 409, -+ f"Can't delete allowlist as it's currently in use by agent {agent.agent_id}", -+ ) -+ return - -- try: -- session.query(VerifierAllowlist).filter_by(name=allowlist_name).delete() -- session.commit() -- except SQLAlchemyError as e: -- logger.error("SQLAlchemy Error: %s", e) -- session.close() -- web_util.echo_json_response(self, 500, f"Database error: {e}") -- raise -+ try: -+ session.query(VerifierAllowlist).filter_by(name=allowlist_name).delete() -+ # session.commit() is automatically called by context manager -+ except SQLAlchemyError as e: -+ logger.error("SQLAlchemy Error: %s", e) -+ web_util.echo_json_response(self, 500, f"Database error: {e}") -+ raise - -- # NOTE(kaifeng) 204 Can not have response body, but current helper -- # doesn't support this case. -- self.set_status(204) -- self.set_header("Content-Type", "application/json") -- self.finish() -- logger.info("DELETE returning 204 response for allowlist: %s", allowlist_name) -+ # NOTE(kaifeng) 204 Can not have response body, but current helper -+ # doesn't support this case. -+ self.set_status(204) -+ self.set_header("Content-Type", "application/json") -+ self.finish() -+ logger.info("DELETE returning 204 response for allowlist: %s", allowlist_name) - - def __get_runtime_policy_db_format(self, runtime_policy_name: str) -> Dict[str, Any]: - """Get the IMA policy from the request and return it in Db format""" -@@ -1022,28 +1033,30 @@ class AllowlistHandler(BaseHandler): - if not runtime_policy_db_format: - return - -- session = get_session() -- # don't allow overwritting -- try: -- runtime_policy_count = session.query(VerifierAllowlist).filter_by(name=runtime_policy_name).count() -- if runtime_policy_count > 0: -- web_util.echo_json_response(self, 409, f"Runtime policy with name {runtime_policy_name} already exists") -- logger.warning("Runtime policy with name %s already exists", runtime_policy_name) -- return -- except SQLAlchemyError as e: -- logger.error("SQLAlchemy Error: %s", e) -- raise -+ with session_context() as session: -+ # don't allow overwritting -+ try: -+ runtime_policy_count = session.query(VerifierAllowlist).filter_by(name=runtime_policy_name).count() -+ if runtime_policy_count > 0: -+ web_util.echo_json_response( -+ self, 409, f"Runtime policy with name {runtime_policy_name} already exists" -+ ) -+ logger.warning("Runtime policy with name %s already exists", runtime_policy_name) -+ return -+ except SQLAlchemyError as e: -+ logger.error("SQLAlchemy Error: %s", e) -+ raise - -- try: -- # Add the agent and data -- session.add(VerifierAllowlist(**runtime_policy_db_format)) -- session.commit() -- except SQLAlchemyError as e: -- logger.error("SQLAlchemy Error: %s", e) -- raise -+ try: -+ # Add the agent and data -+ session.add(VerifierAllowlist(**runtime_policy_db_format)) -+ # session.commit() is automatically called by context manager -+ except SQLAlchemyError as e: -+ logger.error("SQLAlchemy Error: %s", e) -+ raise - -- web_util.echo_json_response(self, 201) -- logger.info("POST returning 201") -+ web_util.echo_json_response(self, 201) -+ logger.info("POST returning 201") - - def put(self) -> None: - """Update an allowlist -@@ -1060,32 +1073,34 @@ class AllowlistHandler(BaseHandler): - if not runtime_policy_db_format: - return - -- session = get_session() -- # don't allow creating a new policy -- try: -- runtime_policy_count = session.query(VerifierAllowlist).filter_by(name=runtime_policy_name).count() -- if runtime_policy_count != 1: -- web_util.echo_json_response( -- self, 409, f"Runtime policy with name {runtime_policy_name} does not already exist" -- ) -- logger.warning("Runtime policy with name %s does not already exist", runtime_policy_name) -- return -- except SQLAlchemyError as e: -- logger.error("SQLAlchemy Error: %s", e) -- raise -+ with session_context() as session: -+ # don't allow creating a new policy -+ try: -+ runtime_policy_count = session.query(VerifierAllowlist).filter_by(name=runtime_policy_name).count() -+ if runtime_policy_count != 1: -+ web_util.echo_json_response( -+ self, -+ 404, -+ f"Runtime policy with name {runtime_policy_name} does not already exist, use POST to create", -+ ) -+ logger.warning("Runtime policy with name %s does not already exist", runtime_policy_name) -+ return -+ except SQLAlchemyError as e: -+ logger.error("SQLAlchemy Error: %s", e) -+ raise - -- try: -- # Update the named runtime policy -- session.query(VerifierAllowlist).filter_by(name=runtime_policy_name).update( -- runtime_policy_db_format # pyright: ignore -- ) -- session.commit() -- except SQLAlchemyError as e: -- logger.error("SQLAlchemy Error: %s", e) -- raise -+ try: -+ # Update the named runtime policy -+ session.query(VerifierAllowlist).filter_by(name=runtime_policy_name).update( -+ runtime_policy_db_format # pyright: ignore -+ ) -+ # session.commit() is automatically called by context manager -+ except SQLAlchemyError as e: -+ logger.error("SQLAlchemy Error: %s", e) -+ raise - -- web_util.echo_json_response(self, 201) -- logger.info("PUT returning 201") -+ web_util.echo_json_response(self, 201) -+ logger.info("PUT returning 201") - - def data_received(self, chunk: Any) -> None: - raise NotImplementedError() -@@ -1113,8 +1128,6 @@ class VerifyIdentityHandler(BaseHandler): - - This is useful for 3rd party tools and integrations to independently verify the state of an agent. - """ -- session = get_session() -- - # validate the parameters of our request - if self.request.uri is None: - web_util.echo_json_response(self, 400, "URI not specified") -@@ -1159,36 +1172,37 @@ class VerifyIdentityHandler(BaseHandler): - return - - # get the agent information from the DB -- agent = None -- try: -- agent = ( -- session.query(VerfierMain) -- .options( # type: ignore -- joinedload(VerfierMain.ima_policy).load_only( -- VerifierAllowlist.checksum, VerifierAllowlist.generator # pyright: ignore -+ with session_context() as session: -+ agent = None -+ try: -+ agent = ( -+ session.query(VerfierMain) -+ .options( # type: ignore -+ joinedload(VerfierMain.ima_policy).load_only( -+ VerifierAllowlist.checksum, VerifierAllowlist.generator # pyright: ignore -+ ) - ) -+ .filter_by(agent_id=agent_id) -+ .one_or_none() - ) -- .filter_by(agent_id=agent_id) -- .one_or_none() -- ) -- except SQLAlchemyError as e: -- logger.error("SQLAlchemy Error for agent ID %s: %s", agent_id, e) -+ except SQLAlchemyError as e: -+ logger.error("SQLAlchemy Error for agent ID %s: %s", agent_id, e) - -- if agent is not None: -- agentAttestState = get_AgentAttestStates().get_by_agent_id(agent_id) -- failure = cloud_verifier_common.process_verify_identity_quote( -- agent, quote, nonce, hash_alg, agentAttestState -- ) -- if failure: -- failure_contexts = "; ".join(x.context for x in failure.events) -- web_util.echo_json_response(self, 200, "Success", {"valid": 0, "reason": failure_contexts}) -- logger.info("GET returning 200, but validation failed") -+ if agent is not None: -+ agentAttestState = get_AgentAttestStates().get_by_agent_id(agent_id) -+ failure = cloud_verifier_common.process_verify_identity_quote( -+ agent, quote, nonce, hash_alg, agentAttestState -+ ) -+ if failure: -+ failure_contexts = "; ".join(x.context for x in failure.events) -+ web_util.echo_json_response(self, 200, "Success", {"valid": 0, "reason": failure_contexts}) -+ logger.info("GET returning 200, but validation failed") -+ else: -+ web_util.echo_json_response(self, 200, "Success", {"valid": 1}) -+ logger.info("GET returning 200, validation successful") - else: -- web_util.echo_json_response(self, 200, "Success", {"valid": 1}) -- logger.info("GET returning 200, validation successful") -- else: -- web_util.echo_json_response(self, 404, "agent id not found") -- logger.info("GET returning 404, agaent not found") -+ web_util.echo_json_response(self, 404, "agent id not found") -+ logger.info("GET returning 404, agaent not found") - - def data_received(self, chunk: Any) -> None: - raise NotImplementedError() -@@ -1231,35 +1245,35 @@ class MbpolicyHandler(BaseHandler): - if not params_valid: - return - -- session = get_session() -- if mb_policy_name is None: -- try: -- names_mbpolicies = session.query(VerifierMbpolicy.name).all() -- except SQLAlchemyError as e: -- logger.error("SQLAlchemy Error: %s", e) -- web_util.echo_json_response(self, 500, "Failed to get names of mbpolicies") -- raise -+ with session_context() as session: -+ if mb_policy_name is None: -+ try: -+ names_mbpolicies = session.query(VerifierMbpolicy.name).all() -+ except SQLAlchemyError as e: -+ logger.error("SQLAlchemy Error: %s", e) -+ web_util.echo_json_response(self, 500, "Failed to get names of mbpolicies") -+ raise - -- names_response = [] -- for name in names_mbpolicies: -- names_response.append(name[0]) -- web_util.echo_json_response(self, 200, "Success", {"mbpolicy names": names_response}) -+ names_response = [] -+ for name in names_mbpolicies: -+ names_response.append(name[0]) -+ web_util.echo_json_response(self, 200, "Success", {"mbpolicy names": names_response}) - -- else: -- try: -- mbpolicy = session.query(VerifierMbpolicy).filter_by(name=mb_policy_name).one() -- except NoResultFound: -- web_util.echo_json_response(self, 404, f"Measured boot policy {mb_policy_name} not found") -- return -- except SQLAlchemyError as e: -- logger.error("SQLAlchemy Error: %s", e) -- web_util.echo_json_response(self, 500, "Failed to get mb_policy") -- raise -+ else: -+ try: -+ mbpolicy = session.query(VerifierMbpolicy).filter_by(name=mb_policy_name).one() -+ except NoResultFound: -+ web_util.echo_json_response(self, 404, f"Measured boot policy {mb_policy_name} not found") -+ return -+ except SQLAlchemyError as e: -+ logger.error("SQLAlchemy Error: %s", e) -+ web_util.echo_json_response(self, 500, "Failed to get mb_policy") -+ raise - -- response = {} -- response["name"] = getattr(mbpolicy, "name", None) -- response["mb_policy"] = getattr(mbpolicy, "mb_policy", None) -- web_util.echo_json_response(self, 200, "Success", response) -+ response = {} -+ response["name"] = getattr(mbpolicy, "name", None) -+ response["mb_policy"] = getattr(mbpolicy, "mb_policy", None) -+ web_util.echo_json_response(self, 200, "Success", response) - - def delete(self) -> None: - """Delete a mb_policy -@@ -1271,45 +1285,44 @@ class MbpolicyHandler(BaseHandler): - if not params_valid or mb_policy_name is None: - return - -- session = get_session() -- try: -- mbpolicy = session.query(VerifierMbpolicy).filter_by(name=mb_policy_name).one() -- except NoResultFound: -- web_util.echo_json_response(self, 404, f"Measured boot policy {mb_policy_name} not found") -- return -- except SQLAlchemyError as e: -- logger.error("SQLAlchemy Error: %s", e) -- web_util.echo_json_response(self, 500, "Failed to get mb_policy") -- raise -+ with session_context() as session: -+ try: -+ mbpolicy = session.query(VerifierMbpolicy).filter_by(name=mb_policy_name).one() -+ except NoResultFound: -+ web_util.echo_json_response(self, 404, f"Measured boot policy {mb_policy_name} not found") -+ return -+ except SQLAlchemyError as e: -+ logger.error("SQLAlchemy Error: %s", e) -+ web_util.echo_json_response(self, 500, "Failed to get mb_policy") -+ raise - -- try: -- agent = session.query(VerfierMain).filter_by(mb_policy_id=mbpolicy.id).one_or_none() -- except SQLAlchemyError as e: -- logger.error("SQLAlchemy Error: %s", e) -- raise -- if agent is not None: -- web_util.echo_json_response( -- self, -- 409, -- f"Can't delete mb_policy as it's currently in use by agent {agent.agent_id}", -- ) -- return -+ try: -+ agent = session.query(VerfierMain).filter_by(mb_policy_id=mbpolicy.id).one_or_none() -+ except SQLAlchemyError as e: -+ logger.error("SQLAlchemy Error: %s", e) -+ raise -+ if agent is not None: -+ web_util.echo_json_response( -+ self, -+ 409, -+ f"Can't delete mb_policy as it's currently in use by agent {agent.agent_id}", -+ ) -+ return - -- try: -- session.query(VerifierMbpolicy).filter_by(name=mb_policy_name).delete() -- session.commit() -- except SQLAlchemyError as e: -- logger.error("SQLAlchemy Error: %s", e) -- session.close() -- web_util.echo_json_response(self, 500, f"Database error: {e}") -- raise -+ try: -+ session.query(VerifierMbpolicy).filter_by(name=mb_policy_name).delete() -+ # session.commit() is automatically called by context manager -+ except SQLAlchemyError as e: -+ logger.error("SQLAlchemy Error: %s", e) -+ web_util.echo_json_response(self, 500, f"Database error: {e}") -+ raise - -- # NOTE(kaifeng) 204 Can not have response body, but current helper -- # doesn't support this case. -- self.set_status(204) -- self.set_header("Content-Type", "application/json") -- self.finish() -- logger.info("DELETE returning 204 response for mb_policy: %s", mb_policy_name) -+ # NOTE(kaifeng) 204 Can not have response body, but current helper -+ # doesn't support this case. -+ self.set_status(204) -+ self.set_header("Content-Type", "application/json") -+ self.finish() -+ logger.info("DELETE returning 204 response for mb_policy: %s", mb_policy_name) - - def __get_mb_policy_db_format(self, mb_policy_name: str) -> Dict[str, Any]: - """Get the measured boot policy from the request and return it in Db format""" -@@ -1341,30 +1354,30 @@ class MbpolicyHandler(BaseHandler): - if not mb_policy_db_format: - return - -- session = get_session() -- # don't allow overwritting -- try: -- mbpolicy_count = session.query(VerifierMbpolicy).filter_by(name=mb_policy_name).count() -- if mbpolicy_count > 0: -- web_util.echo_json_response( -- self, 409, f"Measured boot policy with name {mb_policy_name} already exists" -- ) -- logger.warning("Measured boot policy with name %s already exists", mb_policy_name) -- return -- except SQLAlchemyError as e: -- logger.error("SQLAlchemy Error: %s", e) -- raise -+ with session_context() as session: -+ # don't allow overwritting -+ try: -+ mbpolicy_count = session.query(VerifierMbpolicy).filter_by(name=mb_policy_name).count() -+ if mbpolicy_count > 0: -+ web_util.echo_json_response( -+ self, 409, f"Measured boot policy with name {mb_policy_name} already exists" -+ ) -+ logger.warning("Measured boot policy with name %s already exists", mb_policy_name) -+ return -+ except SQLAlchemyError as e: -+ logger.error("SQLAlchemy Error: %s", e) -+ raise - -- try: -- # Add the data -- session.add(VerifierMbpolicy(**mb_policy_db_format)) -- session.commit() -- except SQLAlchemyError as e: -- logger.error("SQLAlchemy Error: %s", e) -- raise -+ try: -+ # Add the data -+ session.add(VerifierMbpolicy(**mb_policy_db_format)) -+ # session.commit() is automatically called by context manager -+ except SQLAlchemyError as e: -+ logger.error("SQLAlchemy Error: %s", e) -+ raise - -- web_util.echo_json_response(self, 201) -- logger.info("POST returning 201") -+ web_util.echo_json_response(self, 201) -+ logger.info("POST returning 201") - - def put(self) -> None: - """Update an mb_policy -@@ -1381,32 +1394,32 @@ class MbpolicyHandler(BaseHandler): - if not mb_policy_db_format: - return - -- session = get_session() -- # don't allow creating a new policy -- try: -- mbpolicy_count = session.query(VerifierMbpolicy).filter_by(name=mb_policy_name).count() -- if mbpolicy_count != 1: -- web_util.echo_json_response( -- self, 409, f"Measured boot policy with name {mb_policy_name} does not already exist" -- ) -- logger.warning("Measured boot policy with name %s does not already exist", mb_policy_name) -- return -- except SQLAlchemyError as e: -- logger.error("SQLAlchemy Error: %s", e) -- raise -+ with session_context() as session: -+ # don't allow creating a new policy -+ try: -+ mbpolicy_count = session.query(VerifierMbpolicy).filter_by(name=mb_policy_name).count() -+ if mbpolicy_count != 1: -+ web_util.echo_json_response( -+ self, 409, f"Measured boot policy with name {mb_policy_name} does not already exist" -+ ) -+ logger.warning("Measured boot policy with name %s does not already exist", mb_policy_name) -+ return -+ except SQLAlchemyError as e: -+ logger.error("SQLAlchemy Error: %s", e) -+ raise - -- try: -- # Update the named mb_policy -- session.query(VerifierMbpolicy).filter_by(name=mb_policy_name).update( -- mb_policy_db_format # pyright: ignore -- ) -- session.commit() -- except SQLAlchemyError as e: -- logger.error("SQLAlchemy Error: %s", e) -- raise -+ try: -+ # Update the named mb_policy -+ session.query(VerifierMbpolicy).filter_by(name=mb_policy_name).update( -+ mb_policy_db_format # pyright: ignore -+ ) -+ # session.commit() is automatically called by context manager -+ except SQLAlchemyError as e: -+ logger.error("SQLAlchemy Error: %s", e) -+ raise - -- web_util.echo_json_response(self, 201) -- logger.info("PUT returning 201") -+ web_util.echo_json_response(self, 201) -+ logger.info("PUT returning 201") - - def data_received(self, chunk: Any) -> None: - raise NotImplementedError() -@@ -1460,17 +1473,18 @@ async def update_agent_api_version(agent: Dict[str, Any], timeout: float = 60.0) - return None - - logger.info("Agent %s new API version %s is supported", agent_id, new_version) -- session = get_session() -- agent["supported_version"] = new_version - -- # Remove keys that should not go to the DB -- agent_db = dict(agent) -- for key in exclude_db: -- if key in agent_db: -- del agent_db[key] -+ with session_context() as session: -+ agent["supported_version"] = new_version - -- session.query(VerfierMain).filter_by(agent_id=agent_id).update(agent_db) # pyright: ignore -- session.commit() -+ # Remove keys that should not go to the DB -+ agent_db = dict(agent) -+ for key in exclude_db: -+ if key in agent_db: -+ del agent_db[key] -+ -+ session.query(VerfierMain).filter_by(agent_id=agent_id).update(agent_db) # pyright: ignore -+ # session.commit() is automatically called by context manager - else: - logger.warning("Agent %s new API version %s is not supported", agent_id, new_version) - return None -@@ -1718,50 +1732,68 @@ async def notify_error( - revocation_notifier.notify(tosend) - if "agent" in notifiers: - verifier_id = config.get("verifier", "uuid", fallback=cloud_verifier_common.DEFAULT_VERIFIER_ID) -- session = get_session() -- agents = session.query(VerfierMain).filter_by(verifier_id=verifier_id).all() -- futures = [] -- loop = asyncio.get_event_loop() -- # Notify all agents asynchronously through a thread pool -- with ThreadPoolExecutor() as pool: -- for agent_db_obj in agents: -- if agent_db_obj.agent_id != agent["agent_id"]: -- agent = _from_db_obj(agent_db_obj) -- if agent["mtls_cert"] and agent["mtls_cert"] != "disabled": -- agent["ssl_context"] = web_util.generate_agent_tls_context( -- "verifier", agent["mtls_cert"], logger=logger -- ) -- func = functools.partial(invoke_notify_error, agent, tosend, timeout=timeout) -- futures.append(await loop.run_in_executor(pool, func)) -- # Wait for all tasks complete in 60 seconds -- try: -- for f in asyncio.as_completed(futures, timeout=60): -- await f -- except asyncio.TimeoutError as e: -- logger.error("Timeout during notifying error to agents: %s", e) -+ with session_context() as session: -+ agents = session.query(VerfierMain).filter_by(verifier_id=verifier_id).all() -+ futures = [] -+ loop = asyncio.get_event_loop() -+ # Notify all agents asynchronously through a thread pool -+ with ThreadPoolExecutor() as pool: -+ for agent_db_obj in agents: -+ if agent_db_obj.agent_id != agent["agent_id"]: -+ agent = _from_db_obj(agent_db_obj) -+ if agent["mtls_cert"] and agent["mtls_cert"] != "disabled": -+ agent["ssl_context"] = web_util.generate_agent_tls_context( -+ "verifier", agent["mtls_cert"], logger=logger -+ ) -+ func = functools.partial(invoke_notify_error, agent, tosend, timeout=timeout) -+ futures.append(await loop.run_in_executor(pool, func)) -+ # Wait for all tasks complete in 60 seconds -+ try: -+ for f in asyncio.as_completed(futures, timeout=60): -+ await f -+ except asyncio.TimeoutError as e: -+ logger.error("Timeout during notifying error to agents: %s", e) - - - async def process_agent( - agent: Dict[str, Any], new_operational_state: int, failure: Failure = Failure(Component.INTERNAL, ["verifier"]) - ) -> None: -- session = get_session() - try: # pylint: disable=R1702 - main_agent_operational_state = agent["operational_state"] - stored_agent = None -- try: -- stored_agent = ( -- session.query(VerfierMain) -- .options( # type: ignore -- joinedload(VerfierMain.ima_policy).load_only(VerifierAllowlist.checksum) # pyright: ignore -- ) -- .options( # type: ignore -- joinedload(VerfierMain.mb_policy).load_only(VerifierMbpolicy.mb_policy) # pyright: ignore -+ -+ # First database operation - read agent data and extract all needed data within session context -+ ima_policy_data = {} -+ mb_policy_data = None -+ with session_context() as session: -+ try: -+ stored_agent = ( -+ session.query(VerfierMain) -+ .options( # type: ignore -+ joinedload(VerfierMain.ima_policy) # Load full IMA policy object including content -+ ) -+ .options( # type: ignore -+ joinedload(VerfierMain.mb_policy).load_only(VerifierMbpolicy.mb_policy) # pyright: ignore -+ ) -+ .filter_by(agent_id=str(agent["agent_id"])) -+ .first() - ) -- .filter_by(agent_id=str(agent["agent_id"])) -- .first() -- ) -- except SQLAlchemyError as e: -- logger.error("SQLAlchemy Error for agent ID %s: %s", agent["agent_id"], e) -+ -+ # Extract IMA policy data within session context to avoid DetachedInstanceError -+ if stored_agent and stored_agent.ima_policy: -+ ima_policy_data = { -+ "checksum": str(stored_agent.ima_policy.checksum), -+ "name": stored_agent.ima_policy.name, -+ "agent_id": str(stored_agent.agent_id), -+ "ima_policy": stored_agent.ima_policy.ima_policy, # Extract the large content too -+ } -+ -+ # Extract MB policy data within session context -+ if stored_agent and stored_agent.mb_policy: -+ mb_policy_data = stored_agent.mb_policy.mb_policy -+ -+ except SQLAlchemyError as e: -+ logger.error("SQLAlchemy Error for agent ID %s: %s", agent["agent_id"], e) - - # if the stored agent could not be recovered from the database, stop polling - if not stored_agent: -@@ -1775,7 +1807,10 @@ async def process_agent( - logger.warning("Agent %s terminated by user.", agent["agent_id"]) - if agent["pending_event"] is not None: - tornado.ioloop.IOLoop.current().remove_timeout(agent["pending_event"]) -- verifier_db_delete_agent(session, agent["agent_id"]) -+ -+ # Second database operation - delete agent -+ with session_context() as session: -+ verifier_db_delete_agent(session, agent["agent_id"]) - return - - # if the user tells us to stop polling because the tenant quote check failed -@@ -1808,11 +1843,16 @@ async def process_agent( - if not failure.recoverable or failure.highest_severity == MAX_SEVERITY_LABEL: - if agent["pending_event"] is not None: - tornado.ioloop.IOLoop.current().remove_timeout(agent["pending_event"]) -- for key in exclude_db: -- if key in agent: -- del agent[key] -- session.query(VerfierMain).filter_by(agent_id=agent["agent_id"]).update(agent) # pyright: ignore -- session.commit() -+ -+ # Third database operation - update agent with failure state -+ with session_context() as session: -+ for key in exclude_db: -+ if key in agent: -+ del agent[key] -+ session.query(VerfierMain).filter_by(agent_id=agent["agent_id"]).update( -+ agent # type: ignore[arg-type] -+ ) -+ # session.commit() is automatically called by context manager - - # propagate all state, but remove none DB keys first (using exclude_db) - try: -@@ -1821,18 +1861,18 @@ async def process_agent( - if key in agent_db: - del agent_db[key] - -- session.query(VerfierMain).filter_by(agent_id=agent_db["agent_id"]).update(agent_db) # pyright: ignore -- session.commit() -+ # Fourth database operation - update agent state -+ with session_context() as session: -+ session.query(VerfierMain).filter_by(agent_id=agent_db["agent_id"]).update(agent_db) # pyright: ignore -+ # session.commit() is automatically called by context manager - except SQLAlchemyError as e: - logger.error("SQLAlchemy Error for agent ID %s: %s", agent["agent_id"], e) - - # Load agent's IMA policy -- runtime_policy = verifier_read_policy_from_cache(stored_agent) -+ runtime_policy = verifier_read_policy_from_cache(ima_policy_data) - - # Get agent's measured boot policy -- mb_policy = None -- if stored_agent.mb_policy is not None: -- mb_policy = stored_agent.mb_policy.mb_policy -+ mb_policy = mb_policy_data - - # If agent was in a failed state we check if we either stop polling - # or just add it again to the event loop -@@ -1876,7 +1916,14 @@ async def process_agent( - ) - - pending = tornado.ioloop.IOLoop.current().call_later( -- interval, invoke_get_quote, agent, mb_policy, runtime_policy, False, timeout=timeout # type: ignore # due to python <3.9 -+ # type: ignore # due to python <3.9 -+ interval, -+ invoke_get_quote, -+ agent, -+ mb_policy, -+ runtime_policy, -+ False, -+ timeout=timeout, - ) - agent["pending_event"] = pending - return -@@ -1911,7 +1958,14 @@ async def process_agent( - next_retry, - ) - tornado.ioloop.IOLoop.current().call_later( -- next_retry, invoke_get_quote, agent, mb_policy, runtime_policy, True, timeout=timeout # type: ignore # due to python <3.9 -+ # type: ignore # due to python <3.9 -+ next_retry, -+ invoke_get_quote, -+ agent, -+ mb_policy, -+ runtime_policy, -+ True, -+ timeout=timeout, - ) - return - -@@ -1980,9 +2034,9 @@ async def activate_agents(agents: List[VerfierMain], verifier_ip: str, verifier_ - - - def get_agents_by_verifier_id(verifier_id: str) -> List[VerfierMain]: -- session = get_session() - try: -- return session.query(VerfierMain).filter_by(verifier_id=verifier_id).all() -+ with session_context() as session: -+ return session.query(VerfierMain).filter_by(verifier_id=verifier_id).all() - except SQLAlchemyError as e: - logger.error("SQLAlchemy Error: %s", e) - return [] -@@ -2007,20 +2061,20 @@ def main() -> None: - os.umask(0o077) - - VerfierMain.metadata.create_all(engine, checkfirst=True) # pyright: ignore -- session = get_session() -- try: -- query_all = session.query(VerfierMain).all() -- for row in query_all: -- if row.operational_state in states.APPROVED_REACTIVATE_STATES: -- row.operational_state = states.START # pyright: ignore -- session.commit() -- except SQLAlchemyError as e: -- logger.error("SQLAlchemy Error: %s", e) -+ with session_context() as session: -+ try: -+ query_all = session.query(VerfierMain).all() -+ for row in query_all: -+ if row.operational_state in states.APPROVED_REACTIVATE_STATES: -+ row.operational_state = states.START # pyright: ignore -+ # session.commit() is automatically called by context manager -+ except SQLAlchemyError as e: -+ logger.error("SQLAlchemy Error: %s", e) - -- num = session.query(VerfierMain.agent_id).count() -- if num > 0: -- agent_ids = session.query(VerfierMain.agent_id).all() -- logger.info("Agent ids in db loaded from file: %s", agent_ids) -+ num = session.query(VerfierMain.agent_id).count() -+ if num > 0: -+ agent_ids = session.query(VerfierMain.agent_id).all() -+ logger.info("Agent ids in db loaded from file: %s", agent_ids) - - logger.info("Starting Cloud Verifier (tornado) on port %s, use to stop", verifier_port) - -diff --git a/keylime/da/examples/sqldb.py b/keylime/da/examples/sqldb.py -index 8efc84e..04a8afb 100644 ---- a/keylime/da/examples/sqldb.py -+++ b/keylime/da/examples/sqldb.py -@@ -1,7 +1,10 @@ - import time -+from contextlib import contextmanager -+from typing import Iterator - - import sqlalchemy - import sqlalchemy.ext.declarative -+from sqlalchemy.orm import sessionmaker - - from keylime import keylime_logging - from keylime.da.record import BaseRecordManagement, base_build_key_list -@@ -45,23 +48,23 @@ class RecordManagement(BaseRecordManagement): - BaseRecordManagement.__init__(self, service) - - self.engine = sqlalchemy.create_engine(self.ps_url._replace(fragment="").geturl(), pool_recycle=1800) -- sm = sqlalchemy.orm.sessionmaker() -- self.session = sqlalchemy.orm.scoped_session(sm) -- self.session.configure(bind=self.engine) -- TableBase.metadata.create_all(self.engine) -- -- def agent_list_retrieval(self, record_prefix="auto", service="auto"): -- if record_prefix == "auto": -- record_prefix = "" -- -- agent_list = [] -+ self.SessionLocal = sessionmaker(bind=self.engine) - -- recordtype = self.get_record_type(service) -- tbl = type2table(recordtype) -- for agentid in self.session.query(tbl.agentid).distinct(): # pylint: disable=no-member -- agent_list.append(agentid[0]) -+ # Create tables if they don't exist -+ TableBase.metadata.create_all(self.engine) - -- return agent_list -+ @contextmanager -+ def session_context(self) -> Iterator: -+ """Context manager for database sessions that ensures proper cleanup.""" -+ session = self.SessionLocal() -+ try: -+ yield session -+ session.commit() -+ except Exception: -+ session.rollback() -+ raise -+ finally: -+ session.close() - - def record_create( - self, -@@ -84,8 +87,9 @@ class RecordManagement(BaseRecordManagement): - d = {"time": recordtime, "agentid": agentid, "record": rcrd} - - try: -- self.session.add((type2table(recordtype))(**d)) # pylint: disable=no-member -- self.session.commit() # pylint: disable=no-member -+ with self.session_context() as session: -+ session.add((type2table(recordtype))(**d)) -+ # session.commit() is automatically called by context manager - except Exception as e: - logger.error("Failed to create attestation record: %s", e) - -@@ -106,23 +110,23 @@ class RecordManagement(BaseRecordManagement): - if f"{end_date}" == "auto": - end_date = self.end_of_times - -- if self.only_last_record_wanted(start_date, end_date): -- attestion_record_rows = ( -- self.session.query(tbl) # pylint: disable=no-member -- .filter(tbl.agentid == record_identifier) -- .order_by(sqlalchemy.desc(tbl.time)) -- .limit(1) -- ) -- -- else: -- attestion_record_rows = self.session.query(tbl).filter( # pylint: disable=no-member -- tbl.agentid == record_identifier -- ) -- -- for row in attestion_record_rows: -- decoded_record_object = self.record_deserialize(row.record) -- self.record_signature_check(decoded_record_object, record_identifier) -- record_list.append(decoded_record_object) -+ with self.session_context() as session: -+ if self.only_last_record_wanted(start_date, end_date): -+ attestion_record_rows = ( -+ session.query(tbl) -+ .filter(tbl.agentid == record_identifier) -+ .order_by(sqlalchemy.desc(tbl.time)) -+ .limit(1) -+ ) -+ -+ else: -+ attestion_record_rows = session.query(tbl).filter(tbl.agentid == record_identifier) -+ -+ for row in attestion_record_rows: -+ decoded_record_object = self.record_deserialize(row.record) -+ self.record_signature_check(decoded_record_object, record_identifier) -+ record_list.append(decoded_record_object) -+ - return record_list - - def build_key_list(self, agent_identifier, service="auto"): -diff --git a/keylime/db/keylime_db.py b/keylime/db/keylime_db.py -index 5620a28..aa49e51 100644 ---- a/keylime/db/keylime_db.py -+++ b/keylime/db/keylime_db.py -@@ -1,7 +1,8 @@ - import os - from configparser import NoOptionError -+from contextlib import contextmanager - from sqlite3 import Connection as SQLite3Connection --from typing import Any, Dict, Optional, cast -+from typing import Any, Iterator, Optional, cast - - from sqlalchemy import create_engine, event - from sqlalchemy.engine import Engine -@@ -22,90 +23,108 @@ def _set_sqlite_pragma(dbapi_connection: SQLite3Connection, _) -> None: - cursor.close() - - --class DBEngineManager: -- service: Optional[str] -- -- def __init__(self) -> None: -- self.service = None -- -- def make_engine(self, service: str) -> Engine: -- """ -- To use: engine = self.make_engine('cloud_verifier') -- """ -- -- # Keep DB related stuff as it is, but read configuration from new -- # configs -- if service == "cloud_verifier": -- config_service = "verifier" -- else: -- config_service = service -- -- self.service = service -- -- try: -- p_sz_m_ovfl = config.get(config_service, "database_pool_sz_ovfl") -- p_sz, m_ovfl = p_sz_m_ovfl.split(",") -- except NoOptionError: -- p_sz = "5" -- m_ovfl = "10" -- -- engine_args: Dict[str, Any] = {} -- -- url = config.get(config_service, "database_url") -- if url: -- logger.info("database_url is set, using it to establish database connection") -- -- # If the keyword sqlite is provided as the database url, use the -- # cv_data.sqlite for the verifier or the file reg_data.sqlite for -- # the registrar, located at the config.WORK_DIR directory -- if url == "sqlite": -+def make_engine(service: str, **engine_args: Any) -> Engine: -+ """Create a database engine for a keylime service.""" -+ # Keep DB related stuff as it is, but read configuration from new -+ # configs -+ if service == "cloud_verifier": -+ config_service = "verifier" -+ else: -+ config_service = service -+ -+ url = config.get(config_service, "database_url") -+ if url: -+ logger.info("database_url is set, using it to establish database connection") -+ -+ # If the keyword sqlite is provided as the database url, use the -+ # cv_data.sqlite for the verifier or the file reg_data.sqlite for -+ # the registrar, located at the config.WORK_DIR directory -+ if url == "sqlite": -+ logger.info( -+ "database_url is set as 'sqlite' keyword, using default values to establish database connection" -+ ) -+ if service == "cloud_verifier": -+ database = "cv_data.sqlite" -+ elif service == "registrar": -+ database = "reg_data.sqlite" -+ else: -+ logger.error("Tried to setup database access for unknown service '%s'", service) -+ raise Exception(f"Unknown service '{service}' for database setup") -+ -+ database_file = os.path.abspath(os.path.join(config.WORK_DIR, database)) -+ url = f"sqlite:///{database_file}" -+ -+ kl_dir = os.path.dirname(os.path.abspath(database_file)) -+ if not os.path.exists(kl_dir): -+ os.makedirs(kl_dir, 0o700) -+ -+ engine_args["connect_args"] = {"check_same_thread": False} -+ -+ if not url.count("sqlite:"): -+ # sqlite does not support setting pool size and max overflow, only -+ # read from the config when it is going to be used -+ try: -+ p_sz_m_ovfl = config.get(config_service, "database_pool_sz_ovfl") -+ p_sz, m_ovfl = p_sz_m_ovfl.split(",") -+ logger.info("database_pool_sz_ovfl is set, pool size = %s, max overflow = %s", p_sz, m_ovfl) -+ except NoOptionError: -+ p_sz = "5" -+ m_ovfl = "10" - logger.info( -- "database_url is set as 'sqlite' keyword, using default values to establish database connection" -+ "database_pool_sz_ovfl is not set, using default pool size = %s, max overflow = %s", p_sz, m_ovfl - ) -- if service == "cloud_verifier": -- database = "cv_data.sqlite" -- elif service == "registrar": -- database = "reg_data.sqlite" -- else: -- logger.error("Tried to setup database access for unknown service '%s'", service) -- raise Exception(f"Unknown service '{service}' for database setup") -- -- database_file = os.path.abspath(os.path.join(config.WORK_DIR, database)) -- url = f"sqlite:///{database_file}" -- -- kl_dir = os.path.dirname(os.path.abspath(database_file)) -- if not os.path.exists(kl_dir): -- os.makedirs(kl_dir, 0o700) -- -- engine_args["connect_args"] = {"check_same_thread": False} - -- if not url.count("sqlite:"): -- engine_args["pool_size"] = int(p_sz) -- engine_args["max_overflow"] = int(m_ovfl) -- engine_args["pool_pre_ping"] = True -+ engine_args["pool_size"] = int(p_sz) -+ engine_args["max_overflow"] = int(m_ovfl) -+ engine_args["pool_pre_ping"] = True - -- # Enable DB debugging -- if config.DEBUG_DB and config.INSECURE_DEBUG: -- engine_args["echo"] = True -+ # Enable DB debugging -+ if config.DEBUG_DB and config.INSECURE_DEBUG: -+ engine_args["echo"] = True - -- engine = create_engine(url, **engine_args) -- return engine -+ engine = create_engine(url, **engine_args) -+ return engine - - - class SessionManager: - engine: Optional[Engine] -+ _scoped_session: Optional[scoped_session] - - def __init__(self) -> None: - self.engine = None -+ self._scoped_session = None - - def make_session(self, engine: Engine) -> Session: - """ - To use: session = self.make_session(engine) - """ - self.engine = engine -- my_session = scoped_session(sessionmaker()) -+ if self._scoped_session is None: -+ self._scoped_session = scoped_session(sessionmaker()) - try: -- my_session.configure(bind=self.engine) # type: ignore -+ self._scoped_session.configure(bind=self.engine) # type: ignore -+ self._scoped_session.configure(expire_on_commit=False) # type: ignore - except SQLAlchemyError as err: - logger.error("Error creating SQL session manager %s", err) -- return cast(Session, my_session()) -+ return cast(Session, self._scoped_session()) -+ -+ @contextmanager -+ def session_context(self, engine: Engine) -> Iterator[Session]: -+ """ -+ Context manager for database sessions that ensures proper cleanup. -+ To use: -+ with session_manager.session_context(engine) as session: -+ # use session -+ """ -+ session = self.make_session(engine) -+ try: -+ yield session -+ session.commit() -+ except Exception: -+ session.rollback() -+ raise -+ finally: -+ # Important: remove the session from the scoped session registry -+ # to prevent connection leaks with scoped_session -+ if self._scoped_session is not None: -+ self._scoped_session.remove() # type: ignore[no-untyped-call] -diff --git a/keylime/migrations/env.py b/keylime/migrations/env.py -index ac98349..a1881f2 100644 ---- a/keylime/migrations/env.py -+++ b/keylime/migrations/env.py -@@ -8,7 +8,7 @@ import sys - - from alembic import context - --from keylime.db.keylime_db import DBEngineManager -+from keylime.db.keylime_db import make_engine - from keylime.db.registrar_db import Base as RegistrarBase - from keylime.db.verifier_db import Base as VerifierBase - -@@ -74,7 +74,7 @@ def run_migrations_offline(): - logger.info("Writing output to %s", file_) - - with open(file_, "w", encoding="utf-8") as buffer: -- engine = DBEngineManager().make_engine(name) -+ engine = make_engine(name) - connection = engine.connect() - context.configure( - connection=connection, -@@ -102,7 +102,7 @@ def run_migrations_online(): - engines = {} - for name in re.split(r",\s*", db_names): - engines[name] = rec = {} -- rec["engine"] = DBEngineManager().make_engine(name) -+ rec["engine"] = make_engine(name) - - for name, rec in engines.items(): - engine = rec["engine"] -diff --git a/keylime/models/base/db.py b/keylime/models/base/db.py -index dd47d63..0229765 100644 ---- a/keylime/models/base/db.py -+++ b/keylime/models/base/db.py -@@ -41,13 +41,6 @@ class DBManager: - - self._service = service - -- try: -- p_sz_m_ovfl = config.get(config_service, "database_pool_sz_ovfl") -- p_sz, m_ovfl = p_sz_m_ovfl.split(",") -- except NoOptionError: -- p_sz = "5" -- m_ovfl = "10" -- - engine_args: Dict[str, Any] = {} - - url = config.get(config_service, "database_url") -@@ -79,6 +72,21 @@ class DBManager: - engine_args["connect_args"] = {"check_same_thread": False} - - if not url.count("sqlite:"): -+ # sqlite does not support setting pool size and max overflow, only -+ # read from the config when it is going to be used -+ try: -+ p_sz_m_ovfl = config.get(config_service, "database_pool_sz_ovfl") -+ p_sz, m_ovfl = p_sz_m_ovfl.split(",") -+ logger.info("database_pool_sz_ovfl is set, pool size = %s, max overflow = %s", p_sz, m_ovfl) -+ except NoOptionError: -+ p_sz = "5" -+ m_ovfl = "10" -+ logger.info( -+ "database_pool_sz_ovfl is not set, using default pool size = %s, max overflow = %s", -+ p_sz, -+ m_ovfl, -+ ) -+ - engine_args["pool_size"] = int(p_sz) - engine_args["max_overflow"] = int(m_ovfl) - engine_args["pool_pre_ping"] = True -diff --git a/keylime/models/base/persistable_model.py b/keylime/models/base/persistable_model.py -index 18f7d0d..a779f0b 100644 ---- a/keylime/models/base/persistable_model.py -+++ b/keylime/models/base/persistable_model.py -@@ -207,10 +207,16 @@ class PersistableModel(BasicModel, metaclass=PersistableModelMeta): - setattr(self._db_mapping_inst, name, field.data_type.db_dump(value, db_manager.engine.dialect)) - - with db_manager.session_context() as session: -- session.add(self._db_mapping_inst) -+ # Merge the potentially detached object into the new session -+ merged_instance = session.merge(self._db_mapping_inst) -+ session.add(merged_instance) -+ # Update our reference to the merged instance -+ self._db_mapping_inst = merged_instance # pylint: disable=attribute-defined-outside-init - - self.clear_changes() - - def delete(self) -> None: - with db_manager.session_context() as session: -- session.delete(self._db_mapping_inst) # type: ignore[no-untyped-call] -+ # Merge the potentially detached object into the new session before deleting -+ merged_instance = session.merge(self._db_mapping_inst) -+ session.delete(merged_instance) # type: ignore[no-untyped-call] -diff --git a/packit-ci.fmf b/packit-ci.fmf -index 2d1e5e5..cb64faf 100644 ---- a/packit-ci.fmf -+++ b/packit-ci.fmf -@@ -101,6 +101,7 @@ adjust: - - /regression/CVE-2023-3674 - - /regression/issue-1380-agent-removed-and-re-added - - /regression/keylime-agent-option-override-through-envvar -+ - /regression/db-connection-leak-reproducer - - /sanity/keylime-secure_mount - - /sanity/opened-conf-files - - /upstream/run_keylime_tests -diff --git a/test/test_verifier_db.py b/test/test_verifier_db.py -index ad72fa6..aae8f8a 100644 ---- a/test/test_verifier_db.py -+++ b/test/test_verifier_db.py -@@ -172,3 +172,102 @@ class TestVerfierDB(unittest.TestCase): - - def tearDown(self): - self.session.close() -+ -+ def test_11_relationship_access_after_session_commit(self): -+ """Test that relationships can be accessed after session commits (DetachedInstanceError fix)""" -+ # This test reproduces the problematic pattern from cloud_verifier_tornado.py -+ # where objects are loaded with joinedload and then accessed after session closes -+ -+ # Create a new session manager and context (like in cloud_verifier_tornado.py) -+ session_manager = SessionManager() -+ -+ # First, load the agent with eager loading for relationships -+ stored_agent = None -+ with session_manager.session_context(self.engine) as session: -+ stored_agent = ( -+ session.query(VerfierMain) -+ .options(joinedload(VerfierMain.ima_policy)) -+ .options(joinedload(VerfierMain.mb_policy)) -+ .filter_by(agent_id=agent_id) -+ .first() -+ ) -+ # Verify agent was loaded correctly -+ self.assertIsNotNone(stored_agent) -+ # session.commit() is automatically called by context manager when exiting -+ -+ # Now verify we can access relationships AFTER the session has been closed -+ # This would previously trigger DetachedInstanceError -+ -+ # Ensure stored_agent is not None before proceeding -+ assert stored_agent is not None -+ -+ # Test accessing ima_policy relationship -+ self.assertIsNotNone(stored_agent.ima_policy) -+ assert stored_agent.ima_policy is not None # Type narrowing for linter -+ self.assertEqual(stored_agent.ima_policy.name, "test-allowlist") -+ # checksum is not set in test data -+ self.assertEqual(stored_agent.ima_policy.checksum, None) -+ -+ # Test accessing the ima_policy.ima_policy attribute (similar to verifier_read_policy_from_cache) -+ ima_policy_content = stored_agent.ima_policy.ima_policy -+ self.assertEqual(ima_policy_content, test_allowlist_data["ima_policy"]) -+ -+ # Test accessing mb_policy relationship -+ self.assertIsNotNone(stored_agent.mb_policy) -+ assert stored_agent.mb_policy is not None # Type narrowing for linter -+ self.assertEqual(stored_agent.mb_policy.name, "test-mbpolicy") -+ -+ # Test accessing the mb_policy.mb_policy attribute (similar to process_agent function) -+ mb_policy_content = stored_agent.mb_policy.mb_policy -+ self.assertEqual(mb_policy_content, test_mbpolicy_data["mb_policy"]) -+ -+ # Test that we can access these relationships multiple times without issues -+ for _ in range(3): -+ self.assertIsNotNone(stored_agent.ima_policy.ima_policy) -+ self.assertIsNotNone(stored_agent.mb_policy.mb_policy) -+ -+ def test_12_persistable_model_cross_session_fix(self): -+ """Test that PersistableModel can handle cross-session operations safely""" -+ # This test would previously fail with DetachedInstanceError before the fix -+ # Note: This is a conceptual test since we don't have actual PersistableModel -+ # subclasses in the test environment, but demonstrates the pattern -+ -+ # Simulate creating a SQLAlchemy object in one session -+ session_manager = SessionManager() -+ -+ # Load an object in one session context -+ test_agent = None -+ with session_manager.session_context(self.engine) as session: -+ test_agent = session.query(VerfierMain).filter_by(agent_id=agent_id).first() -+ self.assertIsNotNone(test_agent) -+ # Session closes here -+ -+ # Ensure test_agent is not None before proceeding -+ assert test_agent is not None -+ -+ # Now simulate using this object in a different session context -+ # This tests the pattern where PersistableModel would use session.add() or session.delete() -+ # on a cross-session object -+ with session_manager.session_context(self.engine) as session: -+ # Before the fix, this would cause DetachedInstanceError -+ # The fix uses session.merge() to handle detached objects safely -+ merged_agent = session.merge(test_agent) -+ assert merged_agent is not None # Type narrowing for linter -+ -+ # Test that we can modify and save the merged object -+ original_port = merged_agent.port -+ # Use setattr to avoid linter issues with Column assignment -+ setattr(merged_agent, "port", 9999) -+ session.add(merged_agent) -+ # session.commit() called automatically by context manager -+ -+ # Verify the change was persisted -+ with session_manager.session_context(self.engine) as session: -+ updated_agent = session.query(VerfierMain).filter_by(agent_id=agent_id).first() -+ assert updated_agent is not None # Type narrowing for linter -+ self.assertEqual(updated_agent.port, 9999) -+ -+ # Restore original value -+ # Use setattr to avoid linter issues -+ setattr(updated_agent, "port", original_port) -+ session.add(updated_agent) diff --git a/0008-mb-support-EV_EFI_HANDOFF_TABLES-events-on-PCR1.patch b/0008-mb-support-EV_EFI_HANDOFF_TABLES-events-on-PCR1.patch deleted file mode 100644 index 80fdde9..0000000 --- a/0008-mb-support-EV_EFI_HANDOFF_TABLES-events-on-PCR1.patch +++ /dev/null @@ -1,29 +0,0 @@ -From d14e0a132cfedd081bffa7a990b9401d5e257cac Mon Sep 17 00:00:00 2001 -From: Sergio Correia -Date: Fri, 8 Aug 2025 16:40:01 +0100 -Subject: [PATCH 8/9] mb: support EV_EFI_HANDOFF_TABLES events on PCR1 - -Allow EV_EFI_HANDOFF_TABLES events on PCR1 alongside the existing -EV_EFI_HANDOFF_TABLES2 support to handle different firmware -implementations, in the example policy. - -Signed-off-by: Sergio Correia ---- - keylime/mba/elchecking/example.py | 1 + - 1 file changed, 1 insertion(+) - -diff --git a/keylime/mba/elchecking/example.py b/keylime/mba/elchecking/example.py -index 2c6f699..a3d918a 100644 ---- a/keylime/mba/elchecking/example.py -+++ b/keylime/mba/elchecking/example.py -@@ -185,6 +185,7 @@ class Example(policies.Policy): - # We only expect one EV_NO_ACTION event at the start. - dispatcher.set((0, "EV_NO_ACTION"), tests.OnceTest(tests.AcceptAll())) - dispatcher.set((1, "EV_CPU_MICROCODE"), tests.OnceTest(tests.AcceptAll())) -+ dispatcher.set((1, "EV_EFI_HANDOFF_TABLES"), tests.OnceTest(tests.AcceptAll())) - dispatcher.set((1, "EV_EFI_HANDOFF_TABLES2"), tests.OnceTest(tests.AcceptAll())) - dispatcher.set((0, "EV_S_CRTM_VERSION"), events_final.get("s_crtms")) - dispatcher.set((0, "EV_EFI_PLATFORM_FIRMWARE_BLOB"), events_final.get("platform_firmware_blobs")) --- -2.47.3 - diff --git a/0009-mb-support-vendor_db-as-logged-by-newer-shim-version.patch b/0009-mb-support-vendor_db-as-logged-by-newer-shim-version.patch deleted file mode 100644 index bcc024e..0000000 --- a/0009-mb-support-vendor_db-as-logged-by-newer-shim-version.patch +++ /dev/null @@ -1,356 +0,0 @@ -From 607b97ac8d414cb57b1ca89925631d41bd7ac04c Mon Sep 17 00:00:00 2001 -From: Sergio Correia -Date: Fri, 8 Aug 2025 16:41:54 +0100 -Subject: [PATCH 9/9] mb: support vendor_db as logged by newer shim versions - -- Updated example policy to properly handle different event structures - for vendor_db validation: - - KeySubsetMulti for EV_EFI_VARIABLE_DRIVER_CONFIG (has SignatureType field) - - SignatureSetMember for EV_EFI_VARIABLE_AUTHORITY (direct signature format) - -- Added method to extract vendor_db from EV_EFI_VARIABLE_AUTHORITY events - in reference state generation (keylime-policy create measured-boot and - the legacy create_mb_refstate script) -- Made vendor_db optional for backward compatibility - -This fixes attestation failures when vendor_db variables are present but -missing from reference states or validated with incorrect test types. - -See: https://github.com/rhboot/shim/pull/728 -Signed-off-by: Sergio Correia ---- - keylime/mba/elchecking/example.py | 45 +++++++++ - keylime/policy/create_mb_policy.py | 30 ++++++ - scripts/create_mb_refstate | 30 ++++++ - test/test_create_mb_policy.py | 142 +++++++++++++++++++++++++++++ - 4 files changed, 247 insertions(+) - -diff --git a/keylime/mba/elchecking/example.py b/keylime/mba/elchecking/example.py -index a3d918a..5a933ac 100644 ---- a/keylime/mba/elchecking/example.py -+++ b/keylime/mba/elchecking/example.py -@@ -21,6 +21,7 @@ from . import policies, tests - # kek - list of allowed KEK keys - # db - list of allowed db keys - # dbx - list of required dbx keys -+# vendor_db - list of allowed vendor_db keys (optional, for newer shim versions) - # mokdig - list of allowed digests of MoKList (PCR 14 EV_IPL) - # mokxdig - list of allowed digests of MoKListX (PCR 14 EV_IPL) - # kernels - list of allowed { -@@ -121,6 +122,10 @@ class Example(policies.Policy): - if req not in refstate: - raise Exception(f"refstate lacks {req}") - -+ # vendor_db is optional for backward compatibility -+ if "vendor_db" not in refstate: -+ refstate["vendor_db"] = [] -+ - dispatcher = tests.Dispatcher(("PCRIndex", "EventType")) - vd_driver_config = tests.VariableDispatch() - vd_authority = tests.VariableDispatch() -@@ -268,6 +273,34 @@ class Example(policies.Policy): - "db", - db_test, - ) -+ # Support vendor_db as logged by newer shim versions -+ # See: https://github.com/rhboot/shim/pull/728 -+ if not has_secureboot and not refstate["vendor_db"]: -+ vendor_db_test = tests.OnceTest(tests.AcceptAll()) -+ else: -+ vendor_db_test = tests.OnceTest( -+ tests.Or( -+ tests.KeySubsetMulti( -+ ["a159c0a5-e494-a74a-87b5-ab155c2bf072", "2616c4c1-4c50-9240-aca9-41f936934328"], -+ sigs_strip0x(refstate["vendor_db"]), -+ ), -+ tests.KeySubsetMulti( -+ ["a5c059a1-94e4-4aa7-87b5-ab155c2bf072", "c1c41626-504c-4092-aca9-41f936934328"], -+ sigs_strip0x(refstate["vendor_db"]), -+ ), -+ ) -+ ) -+ -+ vd_driver_config.set( -+ "cbb219d7-3a3d-9645-a3bc-dad00e67656f", -+ "vendor_db", -+ vendor_db_test, -+ ) -+ vd_driver_config.set( -+ "d719b2cb-3d3a-4596-a3bc-dad00e67656f", -+ "vendor_db", -+ vendor_db_test, -+ ) - - if not has_secureboot and not refstate["dbx"]: - dbx_test = tests.OnceTest(tests.AcceptAll()) -@@ -295,6 +328,18 @@ class Example(policies.Policy): - vd_db_test = tests.OnceTest(tests.AcceptAll()) - vd_authority.set("cbb219d7-3a3d-9645-a3bc-dad00e67656f", "db", vd_db_test) - vd_authority.set("d719b2cb-3d3a-4596-a3bc-dad00e67656f", "db", vd_db_test) -+ # Support vendor_db as logged by newer shim versions in EV_EFI_VARIABLE_AUTHORITY events -+ # See: https://github.com/rhboot/shim/pull/728 -+ # EV_EFI_VARIABLE_AUTHORITY events have different structure than EV_EFI_VARIABLE_DRIVER_CONFIG -+ # They contain direct signature data without SignatureType field -+ if not has_secureboot and not refstate["vendor_db"]: -+ vendor_db_authority_test = tests.OnceTest(tests.AcceptAll()) -+ else: -+ vendor_db_authority_test = tests.OnceTest( -+ tests.IterateTest(tests.SignatureSetMember(sigs_strip0x(refstate["vendor_db"]))) -+ ) -+ vd_authority.set("cbb219d7-3a3d-9645-a3bc-dad00e67656f", "vendor_db", vendor_db_authority_test) -+ vd_authority.set("d719b2cb-3d3a-4596-a3bc-dad00e67656f", "vendor_db", vendor_db_authority_test) - # Accept all SbatLevels of the Shim, because we already checked the hash of the Shim itself. - vd_sbat_level_test = tests.OnceTest(tests.AcceptAll()) - vd_authority.set("50ab5d60-46e0-0043-abb6-3dd810dd8b23", "SbatLevel", vd_sbat_level_test) -diff --git a/keylime/policy/create_mb_policy.py b/keylime/policy/create_mb_policy.py -index 859e652..b2b48f7 100644 ---- a/keylime/policy/create_mb_policy.py -+++ b/keylime/policy/create_mb_policy.py -@@ -93,6 +93,35 @@ def get_keys(events: List[Dict[str, Any]]) -> Dict[str, List[Any]]: - return out - - -+def get_vendor_db(events: List[Dict[str, Any]]) -> Dict[str, List[Any]]: -+ """Get vendor_db signatures from EV_EFI_VARIABLE_AUTHORITY events.""" -+ out: Dict[str, List[Any]] = {"vendor_db": []} -+ -+ for event in events: -+ if "EventType" not in event: -+ continue -+ if event["EventType"] != "EV_EFI_VARIABLE_AUTHORITY": -+ continue -+ if "Event" not in event or "UnicodeName" not in event["Event"]: -+ continue -+ -+ event_name = event["Event"]["UnicodeName"].lower() -+ if event_name == "vendor_db": -+ data = None -+ if "VariableData" in event["Event"]: -+ data = event["Event"]["VariableData"] -+ -+ if data is not None: -+ # VariableData for EV_EFI_VARIABLE_AUTHORITY is a list of signatures -+ for entry in data: -+ if "SignatureOwner" in entry and "SignatureData" in entry: -+ out["vendor_db"].append( -+ {"SignatureOwner": entry["SignatureOwner"], "SignatureData": f"0x{entry['SignatureData']}"} -+ ) -+ -+ return out -+ -+ - def get_kernel(events: List[Dict[str, Any]], secure_boot: bool) -> Dict[str, List[Dict[str, Any]]]: - """Extract digest for Shim, Grub, Linux Kernel and initrd.""" - out = [] -@@ -259,6 +288,7 @@ def create_mb_refstate(args: argparse.Namespace) -> Optional[Dict[str, object]]: - } - ], - **get_keys(events), -+ **get_vendor_db(events), - **get_mok(events), - **get_kernel(events, has_secureboot), - } -diff --git a/scripts/create_mb_refstate b/scripts/create_mb_refstate -index 23cafb9..c98e61d 100755 ---- a/scripts/create_mb_refstate -+++ b/scripts/create_mb_refstate -@@ -78,6 +78,35 @@ def get_keys(events): - return out - - -+def get_vendor_db(events): -+ """Get vendor_db signatures from EV_EFI_VARIABLE_AUTHORITY events.""" -+ out = {"vendor_db": []} -+ -+ for event in events: -+ if "EventType" not in event: -+ continue -+ if event["EventType"] != "EV_EFI_VARIABLE_AUTHORITY": -+ continue -+ if "Event" not in event or "UnicodeName" not in event["Event"]: -+ continue -+ -+ event_name = event["Event"]["UnicodeName"].lower() -+ if event_name == "vendor_db": -+ data = None -+ if "VariableData" in event["Event"]: -+ data = event["Event"]["VariableData"] -+ -+ if data is not None: -+ # VariableData for EV_EFI_VARIABLE_AUTHORITY is a list of signatures -+ for entry in data: -+ if "SignatureOwner" in entry and "SignatureData" in entry: -+ out["vendor_db"].append( -+ {"SignatureOwner": entry["SignatureOwner"], "SignatureData": f"0x{entry['SignatureData']}"} -+ ) -+ -+ return out -+ -+ - def get_kernel(events, secure_boot): - """ - Extract digest for Shim, Grub, Linux Kernel and initrd. -@@ -197,6 +226,7 @@ def main(): - } - ], - **get_keys(events), -+ **get_vendor_db(events), - **get_mok(events), - **get_kernel(events, has_secureboot), - } -diff --git a/test/test_create_mb_policy.py b/test/test_create_mb_policy.py -index b00d8e7..cd32bda 100644 ---- a/test/test_create_mb_policy.py -+++ b/test/test_create_mb_policy.py -@@ -364,6 +364,148 @@ class CreateMeasuredBootPolicy_Test(unittest.TestCase): - for c in test_cases: - self.assertDictEqual(create_mb_policy.get_mok(c["events"]), c["expected"]) - -+ def test_get_vendor_db(self): -+ test_cases = [ -+ {"events": [], "expected": {"vendor_db": []}}, -+ # No EV_EFI_VARIABLE_AUTHORITY events. -+ { -+ "events": [ -+ { -+ "EventType": "EV_EFI_VARIABLE_DRIVER_CONFIG", -+ "Event": {"UnicodeName": "vendor_db", "VariableData": []}, -+ } -+ ], -+ "expected": {"vendor_db": []}, -+ }, -+ # Good vendor_db event with EV_EFI_VARIABLE_AUTHORITY. -+ { -+ "events": [ -+ { -+ "EventType": "EV_EFI_VARIABLE_AUTHORITY", -+ "Event": { -+ "UnicodeName": "vendor_db", -+ "VariableData": [ -+ { -+ "SignatureOwner": "0223eddb-9079-4388-af77-2d65b1c35d3b", -+ "SignatureData": "sig-data-1", -+ } -+ ], -+ }, -+ } -+ ], -+ "expected": { -+ "vendor_db": [ -+ {"SignatureOwner": "0223eddb-9079-4388-af77-2d65b1c35d3b", "SignatureData": "0xsig-data-1"} -+ ] -+ }, -+ }, -+ # Multiple vendor_db signatures. -+ { -+ "events": [ -+ { -+ "EventType": "EV_EFI_VARIABLE_AUTHORITY", -+ "Event": { -+ "UnicodeName": "vendor_db", -+ "VariableData": [ -+ { -+ "SignatureOwner": "0223eddb-9079-4388-af77-2d65b1c35d3b", -+ "SignatureData": "sig-data-1", -+ }, -+ { -+ "SignatureOwner": "77fa9abd-0359-4d32-bd60-28f4e78f784b", -+ "SignatureData": "sig-data-2", -+ }, -+ ], -+ }, -+ } -+ ], -+ "expected": { -+ "vendor_db": [ -+ {"SignatureOwner": "0223eddb-9079-4388-af77-2d65b1c35d3b", "SignatureData": "0xsig-data-1"}, -+ {"SignatureOwner": "77fa9abd-0359-4d32-bd60-28f4e78f784b", "SignatureData": "0xsig-data-2"}, -+ ] -+ }, -+ }, -+ # Missing EventType. -+ { -+ "events": [ -+ { -+ "Event": { -+ "UnicodeName": "vendor_db", -+ "VariableData": [ -+ { -+ "SignatureOwner": "0223eddb-9079-4388-af77-2d65b1c35d3b", -+ "SignatureData": "sig-data-1", -+ } -+ ], -+ } -+ } -+ ], -+ "expected": {"vendor_db": []}, -+ }, -+ # Wrong EventType. -+ { -+ "events": [ -+ { -+ "EventType": "EV_EFI_VARIABLE_DRIVER_CONFIG", -+ "Event": { -+ "UnicodeName": "vendor_db", -+ "VariableData": [ -+ { -+ "SignatureOwner": "0223eddb-9079-4388-af77-2d65b1c35d3b", -+ "SignatureData": "sig-data-1", -+ } -+ ], -+ }, -+ } -+ ], -+ "expected": {"vendor_db": []}, -+ }, -+ # Missing Event. -+ { -+ "events": [{"EventType": "EV_EFI_VARIABLE_AUTHORITY"}], -+ "expected": {"vendor_db": []}, -+ }, -+ # Missing UnicodeName. -+ { -+ "events": [ -+ { -+ "EventType": "EV_EFI_VARIABLE_AUTHORITY", -+ "Event": { -+ "VariableData": [ -+ { -+ "SignatureOwner": "0223eddb-9079-4388-af77-2d65b1c35d3b", -+ "SignatureData": "sig-data-1", -+ } -+ ] -+ }, -+ } -+ ], -+ "expected": {"vendor_db": []}, -+ }, -+ # Wrong UnicodeName. -+ { -+ "events": [ -+ { -+ "EventType": "EV_EFI_VARIABLE_AUTHORITY", -+ "Event": { -+ "UnicodeName": "db", -+ "VariableData": [ -+ { -+ "SignatureOwner": "0223eddb-9079-4388-af77-2d65b1c35d3b", -+ "SignatureData": "sig-data-1", -+ } -+ ], -+ }, -+ } -+ ], -+ "expected": {"vendor_db": []}, -+ }, -+ ] -+ -+ for c in test_cases: -+ self.assertDictEqual(create_mb_policy.get_vendor_db(c["events"]), c["expected"]) -+ - def test_get_kernel(self): - test_cases = [ - {"events": [], "secureboot": False, "expected": {}}, --- -2.47.3 - diff --git a/0010-verifier-Gracefully-shutdown-on-signal.patch b/0010-verifier-Gracefully-shutdown-on-signal.patch deleted file mode 100644 index df7bb23..0000000 --- a/0010-verifier-Gracefully-shutdown-on-signal.patch +++ /dev/null @@ -1,42 +0,0 @@ -From 1b7191098ca3f6d72c6ad218564ae0938a87efd4 Mon Sep 17 00:00:00 2001 -From: Anderson Toshiyuki Sasaki -Date: Mon, 18 Aug 2025 12:22:55 +0000 -Subject: [PATCH 10/13] verifier: Gracefully shutdown on signal - -Wait for the processes to finish when interrupted by a signal. Do not -call exit(0) in the signal handler. - -Assisted-by: Claude 4 Sonnet -Signed-off-by: Anderson Toshiyuki Sasaki ---- - keylime/cloud_verifier_tornado.py | 10 +++++++++- - 1 file changed, 9 insertions(+), 1 deletion(-) - -diff --git a/keylime/cloud_verifier_tornado.py b/keylime/cloud_verifier_tornado.py -index 7553ac8..7065661 100644 ---- a/keylime/cloud_verifier_tornado.py -+++ b/keylime/cloud_verifier_tornado.py -@@ -2138,7 +2138,7 @@ def main() -> None: - revocation_notifier.stop_broker() - for p in processes: - p.join() -- sys.exit(0) -+ # Do not call sys.exit(0) here as it interferes with multiprocessing cleanup - - signal.signal(signal.SIGINT, sig_handler) - signal.signal(signal.SIGTERM, sig_handler) -@@ -2159,3 +2159,11 @@ def main() -> None: - process = Process(target=server_process, args=(task_id, active_agents)) - process.start() - processes.append(process) -+ -+ # Wait for all worker processes to complete -+ try: -+ for p in processes: -+ p.join() -+ except KeyboardInterrupt: -+ # Signal handler will take care of cleanup -+ pass --- -2.47.3 - diff --git a/0011-revocations-Try-to-send-notifications-on-shutdown.patch b/0011-revocations-Try-to-send-notifications-on-shutdown.patch deleted file mode 100644 index b36836e..0000000 --- a/0011-revocations-Try-to-send-notifications-on-shutdown.patch +++ /dev/null @@ -1,308 +0,0 @@ -From af9ac50f5acf1a7d4ad285956b60e60c3c4416b7 Mon Sep 17 00:00:00 2001 -From: Anderson Toshiyuki Sasaki -Date: Wed, 23 Jul 2025 15:39:49 +0200 -Subject: [PATCH 11/13] revocations: Try to send notifications on shutdown - -During verifier shutdown, try to send any pending revocation -notification in a best-effort manner. In future, the pending revocation -notifications should be persisted to be processed during next startup. - -Assisted-by: Claude 4 Sonnet -Signed-off-by: Anderson Toshiyuki Sasaki ---- - keylime/cloud_verifier_tornado.py | 7 + - keylime/revocation_notifier.py | 239 ++++++++++++++++++++++-------- - 2 files changed, 184 insertions(+), 62 deletions(-) - -diff --git a/keylime/cloud_verifier_tornado.py b/keylime/cloud_verifier_tornado.py -index 7065661..89aa703 100644 ---- a/keylime/cloud_verifier_tornado.py -+++ b/keylime/cloud_verifier_tornado.py -@@ -2109,6 +2109,10 @@ def main() -> None: - # Stop server to not accept new incoming connections - server.stop() - -+ # Gracefully shutdown webhook workers to prevent connection errors -+ if "webhook" in revocation_notifier.get_notifiers(): -+ revocation_notifier.shutdown_webhook_workers() -+ - # Wait for all connections to be closed and then stop ioloop - async def stop() -> None: - await server.close_all_connections() -@@ -2136,6 +2140,9 @@ def main() -> None: - def sig_handler(*_: Any) -> None: - if run_revocation_notifier: - revocation_notifier.stop_broker() -+ # Gracefully shutdown webhook workers to prevent connection errors -+ if "webhook" in revocation_notifier.get_notifiers(): -+ revocation_notifier.shutdown_webhook_workers() - for p in processes: - p.join() - # Do not call sys.exit(0) here as it interferes with multiprocessing cleanup -diff --git a/keylime/revocation_notifier.py b/keylime/revocation_notifier.py -index 5a7cc4b..c154028 100644 ---- a/keylime/revocation_notifier.py -+++ b/keylime/revocation_notifier.py -@@ -18,6 +18,174 @@ broker_proc: Optional[Process] = None - - _SOCKET_PATH = "/var/run/keylime/keylime.verifier.ipc" - -+# Global webhook manager instance (initialized when needed) -+_webhook_manager: Optional["WebhookNotificationManager"] = None -+ -+ -+class WebhookNotificationManager: -+ """Manages webhook worker threads and graceful shutdown for revocation notifications.""" -+ -+ def __init__(self) -> None: -+ self._shutdown_event = threading.Event() -+ self._workers: Set[threading.Thread] = set() -+ self._workers_lock = threading.Lock() -+ -+ def notify_webhook(self, tosend: Dict[str, Any]) -> None: -+ """Send webhook notification with worker thread management.""" -+ url = config.get("verifier", "webhook_url", section="revocations", fallback="") -+ # Check if a url was specified -+ if url == "": -+ return -+ -+ # Similarly to notify(), let's convert `tosend' to str to prevent -+ # possible issues with json handling by python-requests. -+ tosend = json.bytes_to_str(tosend) -+ -+ def worker_webhook(tosend: Dict[str, Any], url: str) -> None: -+ is_shutdown_mode = False -+ try: -+ interval = config.getfloat("verifier", "retry_interval") -+ exponential_backoff = config.getboolean("verifier", "exponential_backoff") -+ -+ max_retries = config.getint("verifier", "max_retries") -+ if max_retries <= 0: -+ logger.info("Invalid value found in 'max_retries' option for verifier, using default value") -+ max_retries = 5 -+ -+ # During shutdown, use fewer retries but still make best effort -+ if self._shutdown_event.is_set(): -+ is_shutdown_mode = True -+ max_retries = min(max_retries, 3) # Reduce retries during shutdown but still try -+ logger.info( -+ "Shutdown mode: attempting to send critical revocation notification with %d retries", -+ max_retries, -+ ) -+ -+ # Get TLS options from the configuration -+ (cert, key, trusted_ca, key_password), verify_server_cert = web_util.get_tls_options( -+ "verifier", is_client=True, logger=logger -+ ) -+ -+ # Generate the TLS context using the obtained options -+ tls_context = web_util.generate_tls_context( -+ cert, key, trusted_ca, key_password, is_client=True, logger=logger -+ ) -+ -+ logger.info("Sending revocation event via webhook to %s ...", url) -+ for i in range(max_retries): -+ next_retry = retry.retry_time(exponential_backoff, interval, i, logger) -+ -+ with RequestsClient( -+ url, -+ verify_server_cert, -+ tls_context, -+ ) as client: -+ try: -+ res = client.post("", json=tosend, timeout=5) -+ except requests.exceptions.SSLError as ssl_error: -+ if "TLSV1_ALERT_UNKNOWN_CA" in str(ssl_error): -+ logger.warning( -+ "Keylime does not recognize certificate from peer. Check if verifier 'trusted_server_ca' is configured correctly" -+ ) -+ -+ raise ssl_error from ssl_error -+ except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e: -+ # During shutdown, only suppress errors on the final attempt after all retries exhausted -+ if is_shutdown_mode and i == max_retries - 1: -+ logger.warning( -+ "Final attempt to send revocation notification failed during shutdown: %s", e -+ ) -+ return -+ # Otherwise, let the retry logic handle it -+ raise e -+ -+ if res and res.status_code in [200, 202]: -+ if is_shutdown_mode: -+ logger.info("Successfully sent revocation notification during shutdown") -+ break -+ -+ logger.debug( -+ "Unable to publish revocation message %d times via webhook, " -+ "trying again in %d seconds. " -+ "Server returned status code: %s", -+ i + 1, -+ next_retry, -+ res.status_code, -+ ) -+ -+ # During shutdown, use shorter retry intervals to complete faster -+ if is_shutdown_mode: -+ next_retry = min(next_retry, 2.0) # Cap retry interval during shutdown -+ -+ time.sleep(next_retry) -+ -+ except Exception as e: -+ # Only suppress errors during final shutdown phase and log appropriately -+ if is_shutdown_mode: -+ logger.warning("Failed to send revocation notification during shutdown: %s", e) -+ else: -+ logger.error("Error in webhook worker: %s", e) -+ finally: -+ # Remove this worker from the active set -+ current_thread = threading.current_thread() -+ with self._workers_lock: -+ self._workers.discard(current_thread) -+ -+ w = functools.partial(worker_webhook, tosend, url) -+ t = threading.Thread(target=w, daemon=True) -+ -+ # Add this worker to the active set -+ with self._workers_lock: -+ self._workers.add(t) -+ -+ t.start() -+ -+ def shutdown_workers(self) -> None: -+ """Signal webhook workers to shut down gracefully and wait for them to complete. -+ -+ This gives workers time to complete their critical revocation notifications -+ before the service shuts down completely. -+ """ -+ logger.info("Shutting down webhook workers gracefully...") -+ self._shutdown_event.set() -+ -+ # Give workers generous time to complete critical revocation notifications -+ timeout = 30.0 # Increased timeout for critical security notifications -+ end_time = time.time() + timeout -+ -+ with self._workers_lock: -+ workers_to_wait = list(self._workers) -+ -+ if workers_to_wait: -+ logger.info("Waiting for %d webhook workers to complete revocation notifications...", len(workers_to_wait)) -+ -+ for worker in workers_to_wait: -+ remaining_time = max(0, end_time - time.time()) -+ if remaining_time > 0: -+ logger.debug( -+ "Waiting for webhook worker %s to complete (timeout: %.1f seconds)", worker.name, remaining_time -+ ) -+ worker.join(timeout=remaining_time) -+ if worker.is_alive(): -+ logger.warning("Webhook worker %s did not complete within timeout", worker.name) -+ else: -+ logger.warning("Timeout exceeded while waiting for webhook workers") -+ break -+ -+ # Clean up completed workers -+ with self._workers_lock: -+ self._workers.clear() -+ -+ logger.info("Webhook workers shutdown complete") -+ -+ -+def _get_webhook_manager() -> WebhookNotificationManager: -+ """Get the global webhook manager instance, creating it if needed.""" -+ global _webhook_manager -+ if _webhook_manager is None: -+ _webhook_manager = WebhookNotificationManager() -+ return _webhook_manager -+ - - # return the revocation notification methods for cloud verifier - def get_notifiers() -> Set[str]: -@@ -83,6 +251,12 @@ def stop_broker() -> None: - broker_proc.kill() # pylint: disable=E1101 - - -+def shutdown_webhook_workers() -> None: -+ """Convenience function to shutdown webhook workers using the global manager.""" -+ manager = _get_webhook_manager() -+ manager.shutdown_workers() -+ -+ - def notify(tosend: Dict[str, Any]) -> None: - assert "zeromq" in get_notifiers() - try: -@@ -127,68 +301,9 @@ def notify(tosend: Dict[str, Any]) -> None: - - - def notify_webhook(tosend: Dict[str, Any]) -> None: -- url = config.get("verifier", "webhook_url", section="revocations", fallback="") -- # Check if a url was specified -- if url == "": -- return -- -- # Similarly to notify(), let's convert `tosend' to str to prevent -- # possible issues with json handling by python-requests. -- tosend = json.bytes_to_str(tosend) -- -- def worker_webhook(tosend: Dict[str, Any], url: str) -> None: -- interval = config.getfloat("verifier", "retry_interval") -- exponential_backoff = config.getboolean("verifier", "exponential_backoff") -- -- max_retries = config.getint("verifier", "max_retries") -- if max_retries <= 0: -- logger.info("Invalid value found in 'max_retries' option for verifier, using default value") -- max_retries = 5 -- -- # Get TLS options from the configuration -- (cert, key, trusted_ca, key_password), verify_server_cert = web_util.get_tls_options( -- "verifier", is_client=True, logger=logger -- ) -- -- # Generate the TLS context using the obtained options -- tls_context = web_util.generate_tls_context(cert, key, trusted_ca, key_password, is_client=True, logger=logger) -- -- logger.info("Sending revocation event via webhook to %s ...", url) -- for i in range(max_retries): -- next_retry = retry.retry_time(exponential_backoff, interval, i, logger) -- -- with RequestsClient( -- url, -- verify_server_cert, -- tls_context, -- ) as client: -- try: -- res = client.post("", json=tosend, timeout=5) -- except requests.exceptions.SSLError as ssl_error: -- if "TLSV1_ALERT_UNKNOWN_CA" in str(ssl_error): -- logger.warning( -- "Keylime does not recognize certificate from peer. Check if verifier 'trusted_server_ca' is configured correctly" -- ) -- -- raise ssl_error from ssl_error -- -- if res and res.status_code in [200, 202]: -- break -- -- logger.debug( -- "Unable to publish revocation message %d times via webhook, " -- "trying again in %d seconds. " -- "Server returned status code: %s", -- i + 1, -- next_retry, -- res.status_code, -- ) -- -- time.sleep(next_retry) -- -- w = functools.partial(worker_webhook, tosend, url) -- t = threading.Thread(target=w, daemon=True) -- t.start() -+ """Send webhook notification using the global webhook manager.""" -+ manager = _get_webhook_manager() -+ manager.notify_webhook(tosend) - - - cert_key = None --- -2.47.3 - diff --git a/0012-requests_client-close-the-session-at-the-end-of-the-.patch b/0012-requests_client-close-the-session-at-the-end-of-the-.patch deleted file mode 100644 index ba24d60..0000000 --- a/0012-requests_client-close-the-session-at-the-end-of-the-.patch +++ /dev/null @@ -1,45 +0,0 @@ -From 5fb4484b07a7ba3fcdf451bf816b5f07a40d6d97 Mon Sep 17 00:00:00 2001 -From: Sergio Correia -Date: Wed, 4 Jun 2025 19:52:37 +0100 -Subject: [PATCH 12/13] requests_client: close the session at the end of the - resource manager - -We had an issue in the past in which the webhook worker would not -properly close the opened session. This was fixed in #1456 (Close -session in worker_webhook function). - -At some later point, in #1566 (revocation_notifier: Take into account CA -certificates added via configuration), some refactoring around the -webhook_worker() in revocation_notifier happened and it started using -the RequestsClient resource manager. - -However, the RequestsClient does not close the session at its end, which -in turns makes that the old issue of not closing properly the session -in the webhook_worker() returned. - -We now issue a session.close() at the end of the RequestsClient. - -Signed-off-by: Sergio Correia ---- - keylime/requests_client.py | 5 ++++- - 1 file changed, 4 insertions(+), 1 deletion(-) - -diff --git a/keylime/requests_client.py b/keylime/requests_client.py -index 16615f7..b7da484 100644 ---- a/keylime/requests_client.py -+++ b/keylime/requests_client.py -@@ -40,7 +40,10 @@ class RequestsClient: - return self - - def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: -- pass -+ try: -+ self.session.close() -+ except Exception: -+ pass - - def request(self, method: str, url: str, **kwargs: Any) -> requests.Response: - return self.session.request(method, self.base_url + url, **kwargs) --- -2.47.3 - diff --git a/0013-fix-malformed-certs-workaround.patch b/0013-fix-malformed-certs-workaround.patch deleted file mode 100644 index 05c3f41..0000000 --- a/0013-fix-malformed-certs-workaround.patch +++ /dev/null @@ -1,1265 +0,0 @@ -diff --git a/keylime/certificate_wrapper.py b/keylime/certificate_wrapper.py -new file mode 100644 -index 0000000..899a19a ---- /dev/null -+++ b/keylime/certificate_wrapper.py -@@ -0,0 +1,99 @@ -+""" -+X.509 Certificate wrapper that preserves original bytes for malformed certificates. -+ -+This module provides a wrapper around cryptography.x509.Certificate that preserves -+the original certificate bytes when the certificate required pyasn1 re-encoding -+due to ASN.1 DER non-compliance. This ensures signature validity is maintained -+throughout the database lifecycle. -+""" -+ -+import base64 -+from typing import Any, Dict, Optional -+ -+import cryptography.x509 -+from cryptography.hazmat.primitives.serialization import Encoding -+ -+ -+class CertificateWrapper: -+ """ -+ A wrapper around cryptography.x509.Certificate that preserves original bytes -+ when malformed certificates require pyasn1 re-encoding. -+ -+ This class wraps a cryptography.x509.Certificate and adds the ability -+ to store the original certificate bytes when the certificate was malformed -+ and required re-encoding using pyasn1. This ensures that signature validation -+ works correctly even for certificates that don't strictly follow ASN.1 DER. -+ """ -+ -+ def __init__(self, cert: cryptography.x509.Certificate, original_bytes: Optional[bytes] = None): -+ """ -+ Initialize the wrapper certificate. -+ -+ :param cert: The cryptography.x509.Certificate object -+ :param original_bytes: The original DER bytes if certificate was re-encoded, None otherwise -+ """ -+ self._cert = cert -+ self._original_bytes = original_bytes -+ -+ def __getattr__(self, name: str) -> Any: -+ """Delegate attribute access to the wrapped certificate.""" -+ return getattr(self._cert, name) -+ -+ def __setstate__(self, state: Dict[str, Any]) -> None: -+ """Support for pickling.""" -+ self.__dict__.update(state) -+ -+ def __getstate__(self) -> Dict[str, Any]: -+ """Support for pickling.""" -+ return self.__dict__ -+ -+ @property -+ def has_original_bytes(self) -> bool: -+ """Check if this certificate has preserved original bytes.""" -+ return self._original_bytes is not None -+ -+ @property -+ def original_bytes(self) -> Optional[bytes]: -+ """Return the preserved original bytes if available.""" -+ return self._original_bytes -+ -+ def public_bytes(self, encoding: Encoding) -> bytes: -+ """ -+ Return certificate bytes, using original bytes when available. -+ -+ For certificates with preserved original bytes, this method always uses -+ the original DER bytes to maintain signature validity. For PEM encoding, -+ it converts the original DER bytes to PEM format. -+ """ -+ if self.has_original_bytes: -+ if encoding == Encoding.DER: -+ return self._original_bytes # type: ignore[return-value] -+ if encoding == Encoding.PEM: -+ # Convert original DER bytes to PEM format -+ der_b64 = base64.b64encode(self._original_bytes).decode("utf-8") # type: ignore[arg-type] -+ # Split into 64-character lines per PEM specification (RFC 1421) -+ lines = [der_b64[i : i + 64] for i in range(0, len(der_b64), 64)] -+ # Create PEM format with proper headers -+ pem_content = "\n".join(["-----BEGIN CERTIFICATE-----"] + lines + ["-----END CERTIFICATE-----"]) + "\n" -+ return pem_content.encode("utf-8") -+ -+ # For certificates without original bytes, use standard method -+ return self._cert.public_bytes(encoding) -+ -+ # Delegate common certificate methods to maintain full compatibility -+ def __str__(self) -> str: -+ return f"CertificateWrapper(subject={self._cert.subject})" -+ -+ def __repr__(self) -> str: -+ return f"CertificateWrapper(subject={self._cert.subject}, has_original_bytes={self.has_original_bytes})" -+ -+ -+def wrap_certificate(cert: cryptography.x509.Certificate, original_bytes: Optional[bytes] = None) -> CertificateWrapper: -+ """ -+ Factory function to create a wrapped certificate. -+ -+ :param cert: The cryptography.x509.Certificate object -+ :param original_bytes: The original DER bytes if certificate was re-encoded -+ :returns: Wrapped certificate that preserves original bytes -+ """ -+ return CertificateWrapper(cert, original_bytes) -diff --git a/keylime/models/base/types/certificate.py b/keylime/models/base/types/certificate.py -index 0f03169..f6cdd48 100644 ---- a/keylime/models/base/types/certificate.py -+++ b/keylime/models/base/types/certificate.py -@@ -12,6 +12,7 @@ from pyasn1_modules import pem as pyasn1_pem - from pyasn1_modules import rfc2459 as pyasn1_rfc2459 - from sqlalchemy.types import Text - -+from keylime.certificate_wrapper import CertificateWrapper, wrap_certificate - from keylime.models.base.type import ModelType - - -@@ -78,19 +79,20 @@ class Certificate(ModelType): - cert = Certificate().cast("-----BEGIN CERTIFICATE-----\nMIIE...") - """ - -- IncomingValue = Union[cryptography.x509.Certificate, bytes, str, None] -+ IncomingValue = Union[cryptography.x509.Certificate, CertificateWrapper, bytes, str, None] - - def __init__(self) -> None: - super().__init__(Text) - -- def _load_der_cert(self, der_cert_data: bytes) -> cryptography.x509.Certificate: -- """Loads a binary x509 certificate encoded using ASN.1 DER as a ``cryptography.x509.Certificate`` object. This -+ def _load_der_cert(self, der_cert_data: bytes) -> CertificateWrapper: -+ """Loads a binary x509 certificate encoded using ASN.1 DER as a ``CertificateWrapper`` object. This - method does not require strict adherence to ASN.1 DER thereby making it possible to accept certificates which do - not follow every detail of the spec (this is the case for a number of TPM certs) [1,2]. - - It achieves this by first using the strict parser provided by python-cryptography. If that fails, it decodes the - certificate and re-encodes it using the more-forgiving pyasn1 library. The re-encoded certificate is then -- re-parsed by python-cryptography. -+ re-parsed by python-cryptography. For malformed certificates requiring re-encoding, the original bytes are -+ preserved in the wrapper to maintain signature validity. - - This method is equivalent to the ``cert_utils.x509_der_cert`` function but does not produce a warning when the - backup parser is used, allowing this condition to be optionally detected and handled by the model where -@@ -106,24 +108,28 @@ class Certificate(ModelType): - - :raises: :class:`SubstrateUnderrunError`: cert could not be deserialized even using the fallback pyasn1 parser - -- :returns: A ``cryptography.x509.Certificate`` object -+ :returns: A ``CertificateWrapper`` object - """ - - try: -- return cryptography.x509.load_der_x509_certificate(der_cert_data) -+ cert = cryptography.x509.load_der_x509_certificate(der_cert_data) -+ return wrap_certificate(cert, None) - except Exception: - pyasn1_cert = pyasn1_decoder.decode(der_cert_data, asn1Spec=pyasn1_rfc2459.Certificate())[0] -- return cryptography.x509.load_der_x509_certificate(pyasn1_encoder.encode(pyasn1_cert)) -+ cert = cryptography.x509.load_der_x509_certificate(pyasn1_encoder.encode(pyasn1_cert)) -+ # Preserve the original bytes when re-encoding is necessary -+ return wrap_certificate(cert, der_cert_data) - -- def _load_pem_cert(self, pem_cert_data: str) -> cryptography.x509.Certificate: -+ def _load_pem_cert(self, pem_cert_data: str) -> CertificateWrapper: - """Loads a text x509 certificate encoded using PEM (Base64ed DER with header and footer) as a -- ``cryptography.x509.Certificate`` object. This method does not require strict adherence to ASN.1 DER thereby -+ ``CertificateWrapper`` object. This method does not require strict adherence to ASN.1 DER thereby - making it possible to accept certificates which do not follow every detail of the spec (this is the case for - a number of TPM certs) [1,2]. - - It achieves this by first using the strict parser provided by python-cryptography. If that fails, it decodes the - certificate and re-encodes it using the more-forgiving pyasn1 library. The re-encoded certificate is then -- re-parsed by python-cryptography. -+ re-parsed by python-cryptography. For malformed certificates requiring re-encoding, the original DER bytes are -+ preserved in the wrapper to maintain signature validity. - - This method is equivalent to the ``cert_utils.x509_der_cert`` function but does not produce a warning when the - backup parser is used, allowing this condition to be optionally detected and handled by the model where -@@ -135,19 +141,24 @@ class Certificate(ModelType): - [2] https://github.com/pyca/cryptography/issues/7189 - [3] https://github.com/keylime/keylime/issues/1559 - -- :param der_cert_data: the DER bytes of the certificate -+ :param pem_cert_data: the PEM text of the certificate - - :raises: :class:`SubstrateUnderrunError`: cert could not be deserialized even using the fallback pyasn1 parser - -- :returns: A ``cryptography.x509.Certificate`` object -+ :returns: A ``CertificateWrapper`` object - """ - - try: -- return cryptography.x509.load_pem_x509_certificate(pem_cert_data.encode("utf-8")) -+ cert = cryptography.x509.load_pem_x509_certificate(pem_cert_data.encode("utf-8")) -+ return wrap_certificate(cert, None) - except Exception: - der_data = pyasn1_pem.readPemFromFile(io.StringIO(pem_cert_data)) - pyasn1_cert = pyasn1_decoder.decode(der_data, asn1Spec=pyasn1_rfc2459.Certificate())[0] -- return cryptography.x509.load_der_x509_certificate(pyasn1_encoder.encode(pyasn1_cert)) -+ cert = cryptography.x509.load_der_x509_certificate(pyasn1_encoder.encode(pyasn1_cert)) -+ # Only preserve original bytes if we have valid DER data -+ original_bytes = der_data if isinstance(der_data, bytes) and der_data else None -+ # Preserve the original bytes when re-encoding is necessary -+ return wrap_certificate(cert, original_bytes) - - def infer_encoding(self, value: IncomingValue) -> Optional[str]: - """Tries to infer the certificate encoding from the given value based on the data type and other surface-level -@@ -159,15 +170,21 @@ class Certificate(ModelType): - :returns: ``"der"`` when the value appears to be DER encoded - :returns: ``"pem"`` when the value appears to be PEM encoded - :returns: ``"base64"`` when the value appears to be Base64(DER) encoded (without PEM headers) -+ :returns: ``"wrapped"`` when the value is already a ``CertificateWrapper`` object - :returns: ``"decoded"`` when the value is already a ``cryptography.x509.Certificate`` object -+ :returns: ``"disabled"`` when the value is the string "disabled" - :returns: ``None`` when the encoding cannot be inferred - """ - # pylint: disable=no-else-return - -- if isinstance(value, cryptography.x509.Certificate): -+ if isinstance(value, CertificateWrapper): -+ return "wrapped" -+ elif isinstance(value, cryptography.x509.Certificate): - return "decoded" - elif isinstance(value, bytes): - return "der" -+ elif isinstance(value, str) and value == "disabled": -+ return "disabled" - elif isinstance(value, str) and value.startswith("-----BEGIN CERTIFICATE-----"): - return "pem" - elif isinstance(value, str): -@@ -190,19 +207,25 @@ class Certificate(ModelType): - :param value: The value in DER, Base64(DER), or PEM format (or an already deserialized certificate object) - - :returns: ``"True"`` if the value can be deserialized by python-cryptography and is ASN.1 DER compliant -+ :returns: ``"True"`` if the value is the string "disabled" (considered compliant as it's a valid field value) - :returns: ``"False"`` if the value cannot be deserialized by python-cryptography - :returns: ``None`` if the value is already a deserialized certificate of type ``cryptography.x509.Certificate`` - """ - - try: - encoding_inf = self.infer_encoding(value) -+ if encoding_inf == "wrapped": -+ # For CertificateWrapper objects, check if they have original bytes (indicating re-encoding was needed) -+ return not value.has_original_bytes # type: ignore[union-attr] - if encoding_inf == "decoded": - return None -+ if encoding_inf == "disabled": -+ return True - - if encoding_inf == "der": - cryptography.x509.load_der_x509_certificate(value) # type: ignore[reportArgumentType, arg-type] - elif encoding_inf == "pem": -- cryptography.x509.load_pem_x509_certificate(value) # type: ignore[reportArgumentType, arg-type] -+ cryptography.x509.load_pem_x509_certificate(value.encode("utf-8")) # type: ignore[reportArgumentType, arg-type, union-attr] - elif encoding_inf == "base64": - der_value = base64.b64decode(value, validate=True) # type: ignore[reportArgumentType, arg-type] - cryptography.x509.load_der_x509_certificate(der_value) -@@ -213,25 +236,27 @@ class Certificate(ModelType): - - return True - -- def cast(self, value: IncomingValue) -> Optional[cryptography.x509.Certificate]: -+ def cast(self, value: IncomingValue) -> Optional[CertificateWrapper]: - """Tries to interpret the given value as an X.509 certificate and convert it to a -- ``cryptography.x509.Certificate`` object. Values which do not require conversion are returned unchanged. -+ ``CertificateWrapper`` object. Values which do not require conversion are returned unchanged. - - :param value: The value to convert (may be in DER, Base64(DER), or PEM format) - - :raises: :class:`TypeError`: ``value`` is of an unexpected data type - :raises: :class:`ValueError`: ``value`` does not contain data which is interpretable as a certificate - -- :returns: A ``cryptography.x509.Certificate`` object or None if an empty value is given -+ :returns: A ``CertificateWrapper`` object or None if an empty value is given - """ - - if not value: - return None - - encoding_inf = self.infer_encoding(value) -+ if encoding_inf == "wrapped": -+ return value # type: ignore[return-value] - if encoding_inf == "decoded": -- return value # type: ignore[reportReturnType, return-value] -- -+ # Wrap raw cryptography certificate without original bytes -+ return wrap_certificate(value, None) # type: ignore[arg-type] - if encoding_inf == "der": - try: - return self._load_der_cert(value) # type: ignore[reportArgumentType, arg-type] -@@ -271,7 +296,6 @@ class Certificate(ModelType): - if not cert: - return None - -- # Save as Base64-encoded value (without the PEM "BEGIN" and "END" header/footer for efficiency) - return base64.b64encode(cert.public_bytes(Encoding.DER)).decode("utf-8") - - def render(self, value: IncomingValue) -> Optional[str]: -@@ -281,9 +305,8 @@ class Certificate(ModelType): - if not cert: - return None - -- # Render certificate in PEM format - return cert.public_bytes(Encoding.PEM).decode("utf-8") # type: ignore[no-any-return] - - @property - def native_type(self) -> type: -- return cryptography.x509.Certificate -+ return CertificateWrapper -diff --git a/keylime/models/registrar/registrar_agent.py b/keylime/models/registrar/registrar_agent.py -index b232049..680316b 100644 ---- a/keylime/models/registrar/registrar_agent.py -+++ b/keylime/models/registrar/registrar_agent.py -@@ -1,7 +1,6 @@ - import base64 - import hmac - --import cryptography.x509 - from cryptography.hazmat.primitives.asymmetric import ec, rsa - from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat - -@@ -116,35 +115,35 @@ class RegistrarAgent(PersistableModel): - if not cert_utils.verify_cert(cert, trust_store, cert_type): - self._add_error(cert_field, "must contain a certificate issued by a CA present in the trust store") - -- def _check_cert_compliance(self, cert_field, raw_cert): -+ def _check_cert_compliance(self, cert_field): - new_cert = self.changes.get(cert_field) - old_cert = self.values.get(cert_field) - - # If the certificate field has not been changed, no need to perform check -- if not raw_cert or not new_cert: -+ if not new_cert: -+ return True -+ -+ # If the certificate field is set as "disabled" (for mtls_cert) -+ if new_cert == "disabled": - return True - - # If the new certificate value is the same as the old certificate value, no need to perform check -- if ( -- isinstance(new_cert, cryptography.x509.Certificate) -- and isinstance(old_cert, cryptography.x509.Certificate) -- and new_cert.public_bytes(Encoding.DER) == old_cert.public_bytes(Encoding.DER) -- ): -+ if old_cert and new_cert.public_bytes(Encoding.DER) == old_cert.public_bytes(Encoding.DER): - return True - -- compliant = Certificate().asn1_compliant(raw_cert) -+ compliant = Certificate().asn1_compliant(new_cert) - - if not compliant: - if config.get("registrar", "malformed_cert_action") == "reject": -- self._add_error(cert_field, Certificate().generate_error_msg(raw_cert)) -+ self._add_error(cert_field, Certificate().generate_error_msg(new_cert)) - - return compliant - -- def _check_all_cert_compliance(self, data): -+ def _check_all_cert_compliance(self): - non_compliant_certs = [] - - for field_name in ("ekcert", "iak_cert", "idevid_cert", "mtls_cert"): -- if not self._check_cert_compliance(field_name, data.get(field_name)): -+ if not self._check_cert_compliance(field_name): - non_compliant_certs.append(f"'{field_name}'") - - if not non_compliant_certs: -@@ -291,7 +290,7 @@ class RegistrarAgent(PersistableModel): - # Ensure either an EK or IAK/IDevID is present, depending on configuration - self._check_root_identity_presence() - # Handle certificates which are not fully compliant with ASN.1 DER -- self._check_all_cert_compliance(data) -+ self._check_all_cert_compliance() - - # Basic validation of values - self.validate_required(["aik_tpm"]) -diff --git a/test/test_certificate_modeltype.py b/test/test_certificate_modeltype.py -new file mode 100644 -index 0000000..335ae0f ---- /dev/null -+++ b/test/test_certificate_modeltype.py -@@ -0,0 +1,197 @@ -+""" -+Unit tests for the Certificate ModelType class. -+ -+This module tests the certificate model type functionality including -+encoding inference and ASN.1 compliance checking. -+""" -+ -+import base64 -+import unittest -+ -+import cryptography.x509 -+from cryptography.hazmat.primitives.serialization import Encoding -+ -+from keylime.certificate_wrapper import CertificateWrapper, wrap_certificate -+from keylime.models.base.types.certificate import Certificate -+ -+ -+class TestCertificateModelType(unittest.TestCase): -+ """Test cases for Certificate ModelType class.""" -+ -+ def setUp(self): -+ """Set up test fixtures.""" -+ self.cert_type = Certificate() -+ -+ # Compliant certificate for testing (loads fine with python-cryptography) -+ self.compliant_cert_pem = """-----BEGIN CERTIFICATE----- -+MIIClzCCAX+gAwIBAgIBATANBgkqhkiG9w0BAQsFADAPMQ0wCwYDVQQDDARUZXN0 -+MB4XDTI1MDkxMTEyNDU1MVoXDTI2MDkxMTEyNDU1MVowDzENMAsGA1UEAwwEVGVz -+dDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAO2V27HsMnKczHCaLgf9 -+FtxuorvkA5OMkz6KsW1eyryHr0TJ801prLpeNnMZ3U4pqLMqocMc7T2KO6nPZJxO -+7zRzehyo9pBBVO4pUR1QMGoTWuJQbqNieDQ4V9dW67N5wp/UWEkK6CNNd6aXjswb -+dVaDbIfDL8hMX6Lil3+pTysRWGqjRvBGJxS9r/mYRAvbz1JHPjfegSc0uxnUE+qZ -+SrbWa3TN82LX6jw6tKk0Z3CcPJC6QN+ijCxxAoHyLRYUIgZbAKe/FGRbjO0fuW11 -+L7TcE1k3eaC7RkvotIaCOW/RMOkwKu1MbCzFEA2YRYf9covEwdItzI4FE++ZJrsz -+LhUCAwEAaTANBgkqhkiG9w0BAQsFAAOCAQEAeqqJT0LnmAluAjrsCSK/eYYjwjhZ -+aKMi/iBO10zfb+GvT4yqEL5gnuWxJEx4TTcDww1clvOC1EcPUZFaKR3GIBGy0ZgJ -+zGCfg+sC6liyZ+4PSWSJHD2dT5N3IGp4/hPsrhKnVb9fYbRc0Bc5VHeS9QQoSJDH -+f9EbxCcwdErVllRter29OZCb4XnEEbTqLIKRYVrbsu/t4C+vzi0tmKg5HZXf9PMo -+D28zJGsCAr8sKW/iUKObqDOHEn56lk12NTJmJmi+g6rEikk/0czJlRjSGnJQLjUg -+d4wslruibXBsLPtJw2c6vTC2SV2F1PXwy5j1OKU+D6nxaaItQvWADEjcTg== -+-----END CERTIFICATE-----""" -+ -+ # Malformed certificate that requires pyasn1 re-encoding (fails with python-cryptography) -+ self.malformed_cert_b64 = ( -+ "MIIDUjCCAvegAwIBAgILAI5xYHQ14nH5hdYwCgYIKoZIzj0EAwIwVTFTMB8GA1UEAxMYTnV2b3Rv" -+ "biBUUE0gUm9vdCBDQSAyMTExMCUGA1UEChMeTnV2b3RvbiBUZWNobm9sb2d5IENvcnBvcmF0aW9u" -+ "MAkGA1UEBhMCVFcwHhcNMTkwNzIzMTcxNTEzWhcNMzkwNzE5MTcxNTEzWjAAMIIBIjANBgkqhkiG" -+ "9w0BAQEFAAOCAQ8AMIIBCgKCAQEAk8kCj7srY/Zlvm1795fVXdyX44w5qsd1m5VywMDgSOavzPKO" -+ "kgbHgQNx6Ak5+4Q43EJ/5qsaDBv59F8W7K69maUwcMNq1xpuq0V/LiwgJVAtc3CdvlxtwQrn7+Uq" -+ "ieIGf+i8sGxpeUCSmYHJPTHNHqjQnvUtdGoy/+WO0i7WsAvX3k/gHHr4p58a8urjJ1RG2Lk1g48D" -+ "ESwl+D7atQEPWzgjr6vK/s5KpLrn7M+dh97TUbG1510AOWBPP35MtT8IZbqC4hs2Ol16gT1M3a9e" -+ "+GaMZkItLUwV76vKDNEgTZG8M1C9OItA/xwzlfXbPepzpxWb4kzHS4qZoQtl4vBZrQIDAQABo4IB" -+ "NjCCATIwUAYDVR0RAQH/BEYwRKRCMEAxPjAUBgVngQUCARMLaWQ6NEU1NDQzMDAwEAYFZ4EFAgIT" -+ "B05QQ1Q3NXgwFAYFZ4EFAgMTC2lkOjAwMDcwMDAyMAwGA1UdEwEB/wQCMAAwEAYDVR0lBAkwBwYF" -+ "Z4EFCAEwHwYDVR0jBBgwFoAUI/TiKtO+N0pEl3KVSqKDrtdSVy4wDgYDVR0PAQH/BAQDAgUgMCIG" -+ "A1UdCQQbMBkwFwYFZ4EFAhAxDjAMDAMyLjACAQACAgCKMGkGCCsGAQUFBwEBBF0wWzBZBggrBgEF" -+ "BQcwAoZNaHR0cHM6Ly93d3cubnV2b3Rvbi5jb20vc2VjdXJpdHkvTlRDLVRQTS1FSy1DZXJ0L051" -+ "dm90b24gVFBNIFJvb3QgQ0EgMjExMS5jZXIwCgYIKoZIzj0EAwIDSQAwRgIhAPHOFiBDZd0dfml2" -+ "a/KlPFhmX7Ahpd0Wq11ZUW1/ixviAiEAlex8BB5nsR6w8QrANwCxc7fH/YnbjXfMCFiWzeZH7ps=" -+ ) -+ -+ # Load certificates for testing -+ self.compliant_cert = cryptography.x509.load_pem_x509_certificate(self.compliant_cert_pem.encode()) -+ self.malformed_cert_der = base64.b64decode(self.malformed_cert_b64) -+ -+ def test_infer_encoding_wrapped_certificate(self): -+ """Test that CertificateWrapper objects are identified as 'wrapped'.""" -+ wrapped_cert = wrap_certificate(self.compliant_cert, None) -+ encoding = self.cert_type.infer_encoding(wrapped_cert) -+ self.assertEqual(encoding, "wrapped") -+ -+ def test_infer_encoding_raw_certificate(self): -+ """Test that raw cryptography.x509.Certificate objects are identified as 'decoded'.""" -+ encoding = self.cert_type.infer_encoding(self.compliant_cert) -+ self.assertEqual(encoding, "decoded") -+ -+ def test_infer_encoding_der_bytes(self): -+ """Test that DER bytes are identified as 'der'.""" -+ der_bytes = self.compliant_cert.public_bytes(Encoding.DER) -+ encoding = self.cert_type.infer_encoding(der_bytes) -+ self.assertEqual(encoding, "der") -+ -+ def test_infer_encoding_pem_string(self): -+ """Test that PEM strings are identified as 'pem'.""" -+ encoding = self.cert_type.infer_encoding(self.compliant_cert_pem) -+ self.assertEqual(encoding, "pem") -+ -+ def test_infer_encoding_base64_string(self): -+ """Test that Base64 strings are identified as 'base64'.""" -+ encoding = self.cert_type.infer_encoding(self.malformed_cert_b64) -+ self.assertEqual(encoding, "base64") -+ -+ def test_infer_encoding_none_for_invalid(self): -+ """Test that invalid types return None.""" -+ encoding = self.cert_type.infer_encoding(12345) # type: ignore[arg-type] # Testing invalid type -+ self.assertIsNone(encoding) -+ -+ def test_asn1_compliant_wrapped_without_original_bytes(self): -+ """Test that CertificateWrapper without original bytes is ASN.1 compliant.""" -+ wrapped_cert = wrap_certificate(self.compliant_cert, None) -+ compliant = self.cert_type.asn1_compliant(wrapped_cert) -+ self.assertTrue(compliant) -+ -+ def test_asn1_compliant_wrapped_with_original_bytes(self): -+ """Test that CertificateWrapper with original bytes is not ASN.1 compliant.""" -+ wrapped_cert = wrap_certificate(self.compliant_cert, b"fake_original_bytes") -+ compliant = self.cert_type.asn1_compliant(wrapped_cert) -+ self.assertFalse(compliant) -+ -+ def test_asn1_compliant_raw_certificate(self): -+ """Test that raw cryptography.x509.Certificate returns None (already decoded).""" -+ compliant = self.cert_type.asn1_compliant(self.compliant_cert) -+ self.assertIsNone(compliant) -+ -+ def test_asn1_compliant_pem_strings(self): -+ """Test ASN.1 compliance checking on PEM strings.""" -+ # The regular certificate and TPM certificate from test_registrar_db.py are actually ASN.1 compliant -+ # and can be loaded directly by python-cryptography without requiring pyasn1 re-encoding -+ compliant_regular = self.cert_type.asn1_compliant(self.compliant_cert_pem) -+ # Only test one certificate since both are the same type (ASN.1 compliant) -+ -+ # Should be ASN.1 compliant (True) since it loads fine with python-cryptography -+ self.assertTrue(compliant_regular) -+ -+ def test_asn1_compliant_der_and_base64(self): -+ """Test ASN.1 compliance checking on DER and Base64 formats.""" -+ # Test DER bytes - regular certificate should be compliant -+ der_bytes = self.compliant_cert.public_bytes(Encoding.DER) -+ compliant_der = self.cert_type.asn1_compliant(der_bytes) -+ self.assertTrue(compliant_der) -+ -+ # Test Base64 string - regular certificate should be compliant -+ b64_string = base64.b64encode(der_bytes).decode("utf-8") -+ compliant_b64 = self.cert_type.asn1_compliant(b64_string) -+ self.assertTrue(compliant_b64) -+ -+ def test_asn1_compliant_malformed_certificate(self): -+ """Test ASN.1 compliance checking on a truly malformed certificate.""" -+ # Test the malformed certificate that requires pyasn1 re-encoding -+ compliant = self.cert_type.asn1_compliant(self.malformed_cert_b64) -+ self.assertFalse(compliant) # Should be non-compliant since it needs pyasn1 fallback -+ -+ def test_asn1_compliant_invalid_data(self): -+ """Test that invalid certificate data is not ASN.1 compliant.""" -+ compliant = self.cert_type.asn1_compliant("invalid_certificate_data") -+ self.assertFalse(compliant) -+ -+ def test_cast_wrapped_certificate(self): -+ """Test that CertificateWrapper objects are returned unchanged.""" -+ wrapped_cert = wrap_certificate(self.compliant_cert, None) -+ result = self.cert_type.cast(wrapped_cert) -+ self.assertIs(result, wrapped_cert) -+ -+ def test_cast_raw_certificate_to_wrapped(self): -+ """Test that raw certificates are wrapped without original bytes.""" -+ result = self.cert_type.cast(self.compliant_cert) -+ self.assertIsInstance(result, CertificateWrapper) -+ assert result is not None # For type checker -+ self.assertFalse(result.has_original_bytes) -+ -+ def test_cast_pem_strings(self): -+ """Test casting PEM strings to CertificateWrapper.""" -+ # Test regular certificate - should be ASN.1 compliant, no original bytes needed -+ result_regular = self.cert_type.cast(self.compliant_cert_pem) -+ self.assertIsInstance(result_regular, CertificateWrapper) -+ assert result_regular is not None # For type checker -+ self.assertFalse(result_regular.has_original_bytes) -+ -+ # Note: Only testing compliant certificate since we now use one consistent certificate for all compliant scenarios -+ -+ def test_cast_malformed_certificate(self): -+ """Test casting the malformed certificate that requires pyasn1 re-encoding.""" -+ result = self.cert_type.cast(self.malformed_cert_b64) -+ self.assertIsInstance(result, CertificateWrapper) -+ assert result is not None # For type checker -+ # Malformed certificate should have original bytes since it needs re-encoding -+ self.assertTrue(result.has_original_bytes) -+ -+ def test_cast_der_bytes(self): -+ """Test casting DER bytes to CertificateWrapper.""" -+ der_bytes = self.compliant_cert.public_bytes(Encoding.DER) -+ result = self.cert_type.cast(der_bytes) -+ self.assertIsInstance(result, CertificateWrapper) -+ -+ def test_cast_none_value(self): -+ """Test that None values return None.""" -+ result = self.cert_type.cast(None) -+ self.assertIsNone(result) -+ -+ def test_cast_empty_string(self): -+ """Test that empty strings return None.""" -+ result = self.cert_type.cast("") -+ self.assertIsNone(result) -+ -+ -+if __name__ == "__main__": -+ unittest.main() -diff --git a/test/test_certificate_wrapper.py b/test/test_certificate_wrapper.py -new file mode 100644 -index 0000000..6b47260 ---- /dev/null -+++ b/test/test_certificate_wrapper.py -@@ -0,0 +1,385 @@ -+""" -+Unit tests for the CertificateWrapper class. -+ -+This module tests the certificate wrapper functionality that preserves original bytes -+for malformed certificates requiring pyasn1 re-encoding. -+""" -+ -+import base64 -+import subprocess -+import tempfile -+import unittest -+from unittest.mock import Mock -+ -+import cryptography.x509 -+from cryptography.hazmat.primitives.serialization import Encoding -+from pyasn1.codec.der import decoder as pyasn1_decoder -+from pyasn1.codec.der import encoder as pyasn1_encoder -+from pyasn1_modules import rfc2459 as pyasn1_rfc2459 -+ -+from keylime.certificate_wrapper import CertificateWrapper, wrap_certificate -+ -+ -+class TestCertificateWrapper(unittest.TestCase): -+ """Test cases for CertificateWrapper class.""" -+ -+ def setUp(self): -+ """Set up test fixtures.""" -+ # Malformed certificate (Base64 encoded) that requires pyasn1 re-encoding -+ # This is a real TPM certificate that doesn't strictly follow ASN.1 DER rules -+ self.malformed_cert_b64 = ( -+ "MIIDUjCCAvegAwIBAgILAI5xYHQ14nH5hdYwCgYIKoZIzj0EAwIwVTFTMB8GA1UEAxMYTnV2b3Rv" -+ "biBUUE0gUm9vdCBDQSAyMTExMCUGA1UEChMeTnV2b3RvbiBUZWNobm9sb2d5IENvcnBvcmF0aW9u" -+ "MAkGA1UEBhMCVFcwHhcNMTkwNzIzMTcxNTEzWhcNMzkwNzE5MTcxNTEzWjAAMIIBIjANBgkqhkiG" -+ "9w0BAQEFAAOCAQ8AMIIBCgKCAQEAk8kCj7srY/Zlvm1795fVXdyX44w5qsd1m5VywMDgSOavzPKO" -+ "kgbHgQNx6Ak5+4Q43EJ/5qsaDBv59F8W7K69maUwcMNq1xpuq0V/LiwgJVAtc3CdvlxtwQrn7+Uq" -+ "ieIGf+i8sGxpeUCSmYHJPTHNHqjQnvUtdGoy/+WO0i7WsAvX3k/gHHr4p58a8urjJ1RG2Lk1g48D" -+ "ESwl+D7atQEPWzgjr6vK/s5KpLrn7M+dh97TUbG1510AOWBPP35MtT8IZbqC4hs2Ol16gT1M3a9e" -+ "+GaMZkItLUwV76vKDNEgTZG8M1C9OItA/xwzlfXbPepzpxWb4kzHS4qZoQtl4vBZrQIDAQABo4IB" -+ "NjCCATIwUAYDVR0RAQH/BEYwRKRCMEAxPjAUBgVngQUCARMLaWQ6NEU1NDQzMDAwEAYFZ4EFAgIT" -+ "B05QQ1Q3NXgwFAYFZ4EFAgMTC2lkOjAwMDcwMDAyMAwGA1UdEwEB/wQCMAAwEAYDVR0lBAkwBwYF" -+ "Z4EFCAEwHwYDVR0jBBgwFoAUI/TiKtO+N0pEl3KVSqKDrtdSVy4wDgYDVR0PAQH/BAQDAgUgMCIG" -+ "A1UdCQQbMBkwFwYFZ4EFAhAxDjAMDAMyLjACAQACAgCKMGkGCCsGAQUFBwEBBF0wWzBZBggrBgEF" -+ "BQcwAoZNaHR0cHM6Ly93d3cubnV2b3Rvbi5jb20vc2VjdXJpdHkvTlRDLVRQTS1FSy1DZXJ0L051" -+ "dm90b24gVFBNIFJvb3QgQ0EgMjExMS5jZXIwCgYIKoZIzj0EAwIDSQAwRgIhAPHOFiBDZd0dfml2" -+ "a/KlPFhmX7Ahpd0Wq11ZUW1/ixviAiEAlex8BB5nsR6w8QrANwCxc7fH/YnbjXfMCFiWzeZH7ps=" -+ ) -+ self.malformed_cert_der = base64.b64decode(self.malformed_cert_b64) -+ -+ # Create a mock certificate for testing -+ self.mock_cert = Mock(spec=cryptography.x509.Certificate) -+ self.mock_cert.subject = Mock() -+ self.mock_cert.subject.__str__ = Mock(return_value="CN=Test Certificate") -+ self.mock_cert.public_bytes.return_value = b"mock_der_data" -+ -+ def test_init_without_original_bytes(self): -+ """Test wrapper initialization without original bytes.""" -+ wrapper = CertificateWrapper(self.mock_cert) -+ -+ # Test through public interface -+ self.assertFalse(wrapper.has_original_bytes) -+ self.assertIsNone(wrapper.original_bytes) -+ # Test delegation works -+ self.assertEqual(wrapper.subject, self.mock_cert.subject) -+ -+ def test_init_with_original_bytes(self): -+ """Test wrapper initialization with original bytes.""" -+ original_data = b"original_certificate_data" -+ wrapper = CertificateWrapper(self.mock_cert, original_data) -+ -+ # Test through public interface -+ self.assertTrue(wrapper.has_original_bytes) -+ self.assertEqual(wrapper.original_bytes, original_data) -+ # Test delegation works -+ self.assertEqual(wrapper.subject, self.mock_cert.subject) -+ -+ def test_getattr_delegation(self): -+ """Test that attributes are properly delegated to the wrapped certificate.""" -+ wrapper = CertificateWrapper(self.mock_cert) -+ -+ # Access an attribute that should be delegated -+ result = wrapper.subject -+ self.assertEqual(result, self.mock_cert.subject) -+ -+ def test_public_bytes_der_without_original(self): -+ """Test public_bytes DER encoding without original bytes.""" -+ wrapper = CertificateWrapper(self.mock_cert) -+ -+ result = wrapper.public_bytes(Encoding.DER) -+ -+ self.mock_cert.public_bytes.assert_called_once_with(Encoding.DER) -+ self.assertEqual(result, b"mock_der_data") -+ -+ def test_public_bytes_der_with_original(self): -+ """Test public_bytes DER encoding with original bytes.""" -+ original_data = b"original_certificate_data" -+ wrapper = CertificateWrapper(self.mock_cert, original_data) -+ -+ result = wrapper.public_bytes(Encoding.DER) -+ -+ # Should return original bytes, not call the wrapped certificate -+ self.mock_cert.public_bytes.assert_not_called() -+ self.assertEqual(result, original_data) -+ -+ def test_public_bytes_pem_without_original(self): -+ """Test public_bytes PEM encoding without original bytes.""" -+ self.mock_cert.public_bytes.return_value = b"-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----\n" -+ wrapper = CertificateWrapper(self.mock_cert) -+ -+ result = wrapper.public_bytes(Encoding.PEM) -+ -+ self.mock_cert.public_bytes.assert_called_once_with(Encoding.PEM) -+ self.assertEqual(result, b"-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----\n") -+ -+ def test_public_bytes_pem_with_original(self): -+ """Test public_bytes PEM encoding with original bytes.""" -+ original_data = self.malformed_cert_der -+ wrapper = CertificateWrapper(self.mock_cert, original_data) -+ -+ result = wrapper.public_bytes(Encoding.PEM) -+ -+ # Should not call the wrapped certificate's method -+ self.mock_cert.public_bytes.assert_not_called() -+ -+ # Result should be PEM format derived from original bytes -+ self.assertIsInstance(result, bytes) -+ result_str = result.decode("utf-8") -+ self.assertTrue(result_str.startswith("-----BEGIN CERTIFICATE-----")) -+ self.assertTrue(result_str.endswith("-----END CERTIFICATE-----\n")) -+ -+ # Verify that the PEM content can be converted back to the original DER -+ pem_lines = result_str.strip().split("\n") -+ pem_content = "".join(pem_lines[1:-1]) # Remove headers and join -+ recovered_der = base64.b64decode(pem_content) -+ self.assertEqual(recovered_der, original_data) -+ -+ def test_pem_line_length_compliance(self): -+ """Test that PEM output follows RFC 1421 line length requirements (64 chars).""" -+ original_data = self.malformed_cert_der -+ wrapper = CertificateWrapper(self.mock_cert, original_data) -+ -+ result = wrapper.public_bytes(Encoding.PEM) -+ result_str = result.decode("utf-8") -+ -+ lines = result_str.strip().split("\n") -+ # Check that content lines (excluding headers) are max 64 chars -+ for line in lines[1:-1]: # Skip header and footer -+ self.assertLessEqual(len(line), 64) -+ -+ def test_str_representation(self): -+ """Test string representation of the wrapper.""" -+ wrapper = CertificateWrapper(self.mock_cert) -+ -+ result = str(wrapper) -+ -+ expected = f"CertificateWrapper(subject={self.mock_cert.subject})" -+ self.assertEqual(result, expected) -+ -+ def test_repr_representation_without_original(self): -+ """Test repr representation without original bytes.""" -+ wrapper = CertificateWrapper(self.mock_cert) -+ -+ result = repr(wrapper) -+ -+ expected = f"CertificateWrapper(subject={self.mock_cert.subject}, has_original_bytes=False)" -+ self.assertEqual(result, expected) -+ -+ def test_repr_representation_with_original(self): -+ """Test repr representation with original bytes.""" -+ original_data = b"original_data" -+ wrapper = CertificateWrapper(self.mock_cert, original_data) -+ -+ result = repr(wrapper) -+ -+ expected = f"CertificateWrapper(subject={self.mock_cert.subject}, has_original_bytes=True)" -+ self.assertEqual(result, expected) -+ -+ def test_pickling_support(self): -+ """Test that the wrapper supports pickling operations.""" -+ original_data = b"test_data" -+ wrapper = CertificateWrapper(self.mock_cert, original_data) -+ -+ # Test getstate -+ state = wrapper.__getstate__() -+ self.assertIsInstance(state, dict) -+ self.assertIn("_cert", state) -+ self.assertIn("_original_bytes", state) -+ -+ # Test setstate -+ new_wrapper = CertificateWrapper(Mock(), None) -+ new_wrapper.__setstate__(state) -+ # Verify state was restored correctly through public interface -+ self.assertTrue(new_wrapper.has_original_bytes) -+ self.assertEqual(new_wrapper.original_bytes, original_data) -+ -+ def test_wrap_certificate_function_without_original(self): -+ """Test the wrap_certificate factory function without original bytes.""" -+ wrapper = wrap_certificate(self.mock_cert) -+ -+ self.assertIsInstance(wrapper, CertificateWrapper) -+ self.assertFalse(wrapper.has_original_bytes) -+ self.assertIsNone(wrapper.original_bytes) -+ -+ def test_wrap_certificate_function_with_original(self): -+ """Test the wrap_certificate factory function with original bytes.""" -+ original_data = b"original_certificate_data" -+ wrapper = wrap_certificate(self.mock_cert, original_data) -+ -+ self.assertIsInstance(wrapper, CertificateWrapper) -+ self.assertTrue(wrapper.has_original_bytes) -+ self.assertEqual(wrapper.original_bytes, original_data) -+ -+ def test_real_malformed_certificate_handling(self): -+ """Test with a real malformed certificate that requires pyasn1 re-encoding.""" -+ # This test simulates the scenario where a malformed certificate is processed -+ -+ # Mock the scenario where cryptography fails but pyasn1 succeeds -+ mock_reencoded_cert = Mock(spec=cryptography.x509.Certificate) -+ mock_reencoded_cert.subject = Mock() -+ mock_reencoded_cert.subject.__str__ = Mock(return_value="CN=Nuvoton TPM") -+ -+ # Create wrapper as if it came from the certificate loading process -+ wrapper = wrap_certificate(mock_reencoded_cert, self.malformed_cert_der) -+ -+ # Test that original bytes are preserved -+ self.assertTrue(wrapper.has_original_bytes) -+ self.assertEqual(wrapper.original_bytes, self.malformed_cert_der) -+ -+ # Test DER output uses original bytes -+ der_output = wrapper.public_bytes(Encoding.DER) -+ self.assertEqual(der_output, self.malformed_cert_der) -+ -+ # Test PEM output is derived from original bytes -+ pem_output = wrapper.public_bytes(Encoding.PEM) -+ self.assertIsInstance(pem_output, bytes) -+ -+ # Verify PEM can be converted back to original DER -+ pem_str = pem_output.decode("utf-8") -+ lines = pem_str.strip().split("\n") -+ content = "".join(lines[1:-1]) -+ recovered_der = base64.b64decode(content) -+ self.assertEqual(recovered_der, self.malformed_cert_der) -+ -+ def test_unsupported_encoding_fallback(self): -+ """Test that unsupported encoding types fall back to wrapped certificate.""" -+ # Create a custom encoding that's not DER or PEM -+ custom_encoding = Mock() -+ custom_encoding.name = "CUSTOM" -+ -+ original_data = b"original_data" -+ wrapper = CertificateWrapper(self.mock_cert, original_data) -+ -+ # Should fall back to wrapped certificate for unknown encoding -+ wrapper.public_bytes(custom_encoding) -+ self.mock_cert.public_bytes.assert_called_once_with(custom_encoding) -+ -+ def test_malformed_certificate_cryptography_failure_and_verification(self): -+ """ -+ Comprehensive test demonstrating that the malformed certificate: -+ 1. Fails to load with python-cryptography -+ 2. Can be verified with OpenSSL -+ 3. Is successfully handled by our wrapper after pyasn1 re-encoding -+ """ -+ # Test 1: Demonstrate that python-cryptography fails to load the malformed certificate -+ with self.assertRaises(Exception) as context: -+ cryptography.x509.load_der_x509_certificate(self.malformed_cert_der) -+ -+ # The specific exception type may vary, but it should fail -+ self.assertIsInstance(context.exception, Exception) -+ -+ # Test 2: Demonstrate that pyasn1 can handle the malformed certificate -+ try: -+ # Decode and re-encode using pyasn1 (simulating what the Certificate type does) -+ pyasn1_cert = pyasn1_decoder.decode(self.malformed_cert_der, asn1Spec=pyasn1_rfc2459.Certificate())[0] -+ reencoded_der = pyasn1_encoder.encode(pyasn1_cert) -+ -+ # Now cryptography should be able to load the re-encoded certificate -+ reencoded_cert = cryptography.x509.load_der_x509_certificate(reencoded_der) -+ self.assertIsNotNone(reencoded_cert) -+ -+ except Exception as e: -+ self.fail(f"pyasn1 should handle the malformed certificate, but got: {e}") -+ -+ # Test 3: Verify that our wrapper preserves the original bytes correctly -+ wrapper = wrap_certificate(reencoded_cert, self.malformed_cert_der) -+ -+ # The wrapper should preserve original bytes -+ self.assertTrue(wrapper.has_original_bytes) -+ self.assertEqual(wrapper.original_bytes, self.malformed_cert_der) -+ -+ # DER output should use original bytes -+ der_output = wrapper.public_bytes(Encoding.DER) -+ self.assertEqual(der_output, self.malformed_cert_der) -+ -+ # PEM output should be derived from original bytes -+ pem_output = wrapper.public_bytes(Encoding.PEM) -+ pem_str = pem_output.decode("utf-8") -+ -+ # Verify PEM format is correct -+ self.assertTrue(pem_str.startswith("-----BEGIN CERTIFICATE-----")) -+ self.assertTrue(pem_str.endswith("-----END CERTIFICATE-----\n")) -+ -+ # Test 4: Demonstrate OpenSSL can verify the certificate structure -+ # (Even without the root CA, OpenSSL should be able to parse the certificate) -+ try: -+ with tempfile.NamedTemporaryFile(mode="wb", suffix=".der", delete=False) as temp_file: -+ temp_file.write(self.malformed_cert_der) -+ temp_file.flush() -+ -+ # Use OpenSSL to parse the certificate (should succeed) -+ result = subprocess.run( -+ ["openssl", "x509", "-in", temp_file.name, "-inform", "DER", "-text", "-noout"], -+ capture_output=True, -+ text=True, -+ check=False, -+ ) -+ -+ # OpenSSL should successfully parse the certificate -+ self.assertEqual(result.returncode, 0) -+ self.assertIn("Nuvoton TPM Root CA 2111", result.stdout) -+ self.assertIn("Certificate:", result.stdout) -+ -+ except (subprocess.CalledProcessError, FileNotFoundError) as e: -+ # Skip if OpenSSL is not available, but don't fail the test -+ self.skipTest(f"OpenSSL not available for verification test: {e}") -+ -+ # Test 5: Verify certificate details are accessible through wrapper -+ # The subject should be empty (as shown in the OpenSSL output) -+ self.assertEqual(len(reencoded_cert.subject), 0) -+ -+ # The issuer should contain Nuvoton information -+ issuer_attrs = {} -+ for attr in reencoded_cert.issuer: -+ # Use dotted string representation to avoid accessing private _name -+ oid_name = attr.oid.dotted_string -+ if oid_name == "2.5.4.3": # Common Name OID -+ issuer_attrs["commonName"] = attr.value -+ self.assertIn("commonName", issuer_attrs) -+ self.assertEqual(issuer_attrs["commonName"], "Nuvoton TPM Root CA 2111") -+ -+ # Test 6: Demonstrate that even re-encoded certificates may have parsing issues -+ # This shows why preserving original bytes is crucial -+ try: -+ # Try to access extensions - this may fail due to malformed ASN.1 -+ extensions = list(reencoded_cert.extensions) -+ # If it succeeds, verify it has the expected Subject Alternative Name -+ # Subject Alternative Name OID is 2.5.29.17 -+ has_subject_alt_name = any(ext.oid.dotted_string == "2.5.29.17" for ext in extensions) -+ self.assertTrue(has_subject_alt_name, "EK certificate should have Subject Alternative Name extension") -+ except (ValueError, Exception) as e: -+ # This is actually expected for malformed certificates! -+ # Even after pyasn1 re-encoding, some parsing issues may remain -+ self.assertIn("parsing asn1", str(e).lower(), f"Expected ASN.1 parsing error, got: {e}") -+ # This demonstrates why our wrapper preserves original bytes - -+ # they maintain signature validity even when parsing has issues -+ -+ def test_certificate_chain_verification_simulation(self): -+ """ -+ Test that simulates certificate chain verification where original bytes matter. -+ This demonstrates why preserving original bytes is crucial for signature validation. -+ """ -+ # Create a wrapper with the malformed certificate -+ mock_reencoded_cert = Mock(spec=cryptography.x509.Certificate) -+ mock_reencoded_cert.subject = Mock() -+ mock_reencoded_cert.public_key.return_value = Mock() -+ -+ wrapper = wrap_certificate(mock_reencoded_cert, self.malformed_cert_der) -+ -+ # Simulate signature verification scenario -+ # In real verification, the signature is computed over the exact DER bytes -+ original_bytes_for_verification = wrapper.public_bytes(Encoding.DER) -+ -+ # Should get the original malformed bytes (preserving signature validity) -+ self.assertEqual(original_bytes_for_verification, self.malformed_cert_der) -+ -+ # If we didn't preserve original bytes, we'd get re-encoded bytes which would -+ # invalidate the signature even though the certificate content is the same -+ mock_reencoded_cert.public_bytes.return_value = b"reencoded_different_bytes" -+ -+ # Verify that using the wrapper gets original bytes, not re-encoded bytes -+ self.assertNotEqual(original_bytes_for_verification, b"reencoded_different_bytes") -+ self.assertEqual(original_bytes_for_verification, self.malformed_cert_der) -+ -+ -+if __name__ == "__main__": -+ unittest.main() -diff --git a/test/test_registrar_agent_cert_compliance.py b/test/test_registrar_agent_cert_compliance.py -new file mode 100644 -index 0000000..ede9b9f ---- /dev/null -+++ b/test/test_registrar_agent_cert_compliance.py -@@ -0,0 +1,289 @@ -+""" -+Integration tests for RegistrarAgent certificate compliance functionality. -+ -+This module tests the simplified certificate compliance checking methods -+to ensure they work correctly with the new CertificateWrapper-based approach. -+""" -+ -+import types -+import unittest -+from unittest.mock import Mock, patch -+ -+import cryptography.x509 -+ -+from keylime.certificate_wrapper import wrap_certificate -+from keylime.models.base.types.certificate import Certificate -+from keylime.models.registrar.registrar_agent import RegistrarAgent -+ -+ -+class TestRegistrarAgentCertCompliance(unittest.TestCase): -+ """Test cases for RegistrarAgent certificate compliance methods.""" -+ -+ # pylint: disable=protected-access,not-callable # Testing protected methods and dynamic method binding -+ -+ def setUp(self): -+ """Set up test fixtures.""" -+ # Create a test certificate -+ self.valid_cert_pem = """-----BEGIN CERTIFICATE----- -+MIIEnzCCA4egAwIBAgIEMV64bDANBgkqhkiG9w0BAQUFADBtMQswCQYDVQQGEwJE -+RTEQMA4GA1UECBMHQmF2YXJpYTEhMB8GA1UEChMYSW5maW5lb24gVGVjaG5vbG9n -+aWVzIEFHMQwwCgYDVQQLEwNBSU0xGzAZBgNVBAMTEklGWCBUUE0gRUsgUm9vdCBD -+QTAeFw0wNTEwMjAxMzQ3NDNaFw0yNTEwMjAxMzQ3NDNaMHcxCzAJBgNVBAYTAkRF -+MQ8wDQYDVQQIEwZTYXhvbnkxITAfBgNVBAoTGEluZmluZW9uIFRlY2hub2xvZ2ll -+cyBBRzEMMAoGA1UECxMDQUlNMSYwJAYDVQQDEx1JRlggVFBNIEVLIEludGVybWVk -+aWF0ZSBDQSAwMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALftPhYN -+t4rE+JnU/XOPICbOBLvfo6iA7nuq7zf4DzsAWBdsZEdFJQfaK331ihG3IpQnlQ2i -+YtDim289265f0J4OkPFpKeFU27CsfozVaNUm6UR/uzwA8ncxFc3iZLRMRNLru/Al -+VG053ULVDQMVx2iwwbBSAYO9pGiGbk1iMmuZaSErMdb9v0KRUyZM7yABiyDlM3cz -+UQX5vLWV0uWqxdGoHwNva5u3ynP9UxPTZWHZOHE6+14rMzpobs6Ww2RR8BgF96rh -+4rRAZEl8BXhwiQq4STvUXkfvdpWH4lzsGcDDtrB6Nt3KvVNvsKz+b07Dk+Xzt+EH -+NTf3Byk2HlvX+scCAwEAAaOCATswggE3MB0GA1UdDgQWBBQ4k8292HPEIzMV4bE7 -+qWoNI8wQxzAOBgNVHQ8BAf8EBAMCAgQwEgYDVR0TAQH/BAgwBgEB/wIBADBYBgNV -+HSABAf8ETjBMMEoGC2CGSAGG+EUBBy8BMDswOQYIKwYBBQUHAgEWLWh0dHA6Ly93 -+d3cudmVyaXNpZ24uY29tL3JlcG9zaXRvcnkvaW5kZXguaHRtbDCBlwYDVR0jBIGP -+MIGMgBRW65FEhWPWcrOu1EWWC/eUDlRCpqFxpG8wbTELMAkGA1UEBhMCREUxEDAO -+BgNVBAgTB0JhdmFyaWExITAfBgNVBAoTGEluZmluZW9uIFRlY2hub2xvZ2llcyBB -+RzEMMAoGA1UECxMDQUlNMRswGQYDVQQDExJJRlggVFBNIEVLIFJvb3QgQ0GCAQMw -+DQYJKoZIhvcNAQEFBQADggEBABJ1+Ap3rNlxZ0FW0aIgdzktbNHlvXWNxFdYIBbM -+OKjmbOos0Y4O60eKPu259XmMItCUmtbzF3oKYXq6ybARUT2Lm+JsseMF5VgikSlU -+BJALqpKVjwAds81OtmnIQe2LSu4xcTSavpsL4f52cUAu/maMhtSgN9mq5roYptq9 -+DnSSDZrX4uYiMPl//rBaNDBflhJ727j8xo9CCohF3yQUoQm7coUgbRMzyO64yMIO -+3fhb+Vuc7sNwrMOz3VJN14C3JMoGgXy0c57IP/kD5zGRvljKEvrRC2I147+fPeLS -+DueRMS6lblvRKiZgmGAg7YaKOkOaEmVDMQ+fTo2Po7hI5wc= -+-----END CERTIFICATE-----""" -+ -+ self.valid_cert = cryptography.x509.load_pem_x509_certificate(self.valid_cert_pem.encode()) -+ -+ # Malformed certificate that actually requires pyasn1 re-encoding -+ self.malformed_cert_b64 = ( -+ "MIIDUjCCAvegAwIBAgILAI5xYHQ14nH5hdYwCgYIKoZIzj0EAwIwVTFTMB8GA1UEAxMYTnV2b3Rv" -+ "biBUUE0gUm9vdCBDQSAyMTExMCUGA1UEChMeTnV2b3RvbiBUZWNobm9sb2d5IENvcnBvcmF0aW9u" -+ "MAkGA1UEBhMCVFcwHhcNMTkwNzIzMTcxNTEzWhcNMzkwNzE5MTcxNTEzWjAAMIIBIjANBgkqhkiG" -+ "9w0BAQEFAAOCAQ8AMIIBCgKCAQEAk8kCj7srY/Zlvm1795fVXdyX44w5qsd1m5VywMDgSOavzPKO" -+ "kgbHgQNx6Ak5+4Q43EJ/5qsaDBv59F8W7K69maUwcMNq1xpuq0V/LiwgJVAtc3CdvlxtwQrn7+Uq" -+ "ieIGf+i8sGxpeUCSmYHJPTHNHqjQnvUtdGoy/+WO0i7WsAvX3k/gHHr4p58a8urjJ1RG2Lk1g48D" -+ "ESwl+D7atQEPWzgjr6vK/s5KpLrn7M+dh97TUbG1510AOWBPP35MtT8IZbqC4hs2Ol16gT1M3a9e" -+ "+GaMZkItLUwV76vKDNEgTZG8M1C9OItA/xwzlfXbPepzpxWb4kzHS4qZoQtl4vBZrQIDAQABo4IB" -+ "NjCCATIwUAYDVR0RAQH/BEYwRKRCMEAxPjAUBgVngQUCARMLaWQ6NEU1NDQzMDAwEAYFZ4EFAgIT" -+ "B05QQ1Q3NXgwFAYFZ4EFAgMTC2lkOjAwMDcwMDAyMAwGA1UdEwEB/wQCMAAwEAYDVR0lBAkwBwYF" -+ "Z4EFCAEwHwYDVR0jBBgwFoAUI/TiKtO+N0pEl3KVSqKDrtdSVy4wDgYDVR0PAQH/BAQDAgUgMCIG" -+ "A1UdCQQbMBkwFwYFZ4EFAhAxDjAMDAMyLjACAQACAgCKMGkGCCsGAQUFBwEBBF0wWzBZBggrBgEF" -+ "BQcwAoZNaHR0cHM6Ly93d3cubnV2b3Rvbi5jb20vc2VjdXJpdHkvTlRDLVRQTS1FSy1DZXJ0L051" -+ "dm90b24gVFBNIFJvb3QgQ0EgMjExMS5jZXIwCgYIKoZIzj0EAwIDSQAwRgIhAPHOFiBDZd0dfml2" -+ "a/KlPFhmX7Ahpd0Wq11ZUW1/ixviAiEAlex8BB5nsR6w8QrANwCxc7fH/YnbjXfMCFiWzeZH7ps=" -+ ) -+ -+ # Create wrapped certificates for testing using Certificate type to ensure proper behavior -+ cert_type = Certificate() -+ -+ # Create compliant certificate (no original bytes needed) -+ self.compliant_wrapped_cert = wrap_certificate(self.valid_cert, None) -+ -+ # Create non-compliant certificate using the malformed cert data -+ self.non_compliant_wrapped_cert = cert_type.cast(self.malformed_cert_b64) -+ -+ def create_mock_registrar_agent(self): -+ """Create a mock RegistrarAgent with necessary attributes.""" -+ agent = Mock() -+ agent.changes = {} -+ agent.values = {} -+ agent._add_error = Mock() -+ -+ # Bind the actual methods to the mock instance -+ agent._check_cert_compliance = types.MethodType(RegistrarAgent._check_cert_compliance, agent) -+ agent._check_all_cert_compliance = types.MethodType(RegistrarAgent._check_all_cert_compliance, agent) -+ -+ return agent -+ -+ def test_check_cert_compliance_no_new_cert(self): -+ """Test _check_cert_compliance when no new certificate is provided.""" -+ agent = self.create_mock_registrar_agent() -+ agent.changes = {} # No new certificate -+ -+ result = agent._check_cert_compliance("ekcert") -+ self.assertTrue(result) -+ agent._add_error.assert_not_called() -+ -+ def test_check_cert_compliance_same_cert(self): -+ """Test _check_cert_compliance when new cert is same as old cert.""" -+ agent = self.create_mock_registrar_agent() -+ agent.changes = {"ekcert": self.compliant_wrapped_cert} -+ agent.values = {"ekcert": self.compliant_wrapped_cert} -+ -+ result = agent._check_cert_compliance("ekcert") -+ self.assertTrue(result) -+ agent._add_error.assert_not_called() -+ -+ def test_check_cert_compliance_different_cert_same_der(self): -+ """Test _check_cert_compliance when certificates have same DER bytes.""" -+ agent = self.create_mock_registrar_agent() -+ # Create two different wrapper objects but with same underlying certificate -+ cert1 = wrap_certificate(self.valid_cert, None) -+ cert2 = wrap_certificate(self.valid_cert, None) -+ -+ agent.changes = {"ekcert": cert1} -+ agent.values = {"ekcert": cert2} -+ -+ result = agent._check_cert_compliance("ekcert") -+ self.assertTrue(result) -+ agent._add_error.assert_not_called() -+ -+ @patch("keylime.config.get") -+ def test_check_cert_compliance_compliant_cert(self, mock_config): -+ """Test _check_cert_compliance with ASN.1 compliant certificate.""" -+ mock_config.return_value = "warn" # Default action -+ -+ agent = self.create_mock_registrar_agent() -+ agent.changes = {"ekcert": self.compliant_wrapped_cert} -+ agent.values = {} # No old certificate -+ -+ result = agent._check_cert_compliance("ekcert") -+ self.assertTrue(result) -+ agent._add_error.assert_not_called() -+ -+ @patch("keylime.config.get") -+ def test_check_cert_compliance_non_compliant_cert_warn(self, mock_config): -+ """Test _check_cert_compliance with non-compliant certificate (warn mode).""" -+ mock_config.return_value = "warn" # Warn action -+ -+ agent = self.create_mock_registrar_agent() -+ agent.changes = {"ekcert": self.non_compliant_wrapped_cert} -+ agent.values = {} # No old certificate -+ -+ result = agent._check_cert_compliance("ekcert") -+ self.assertFalse(result) -+ agent._add_error.assert_not_called() # Should not add error in warn mode -+ -+ @patch("keylime.config.get") -+ def test_check_cert_compliance_non_compliant_cert_reject(self, mock_config): -+ """Test _check_cert_compliance with non-compliant certificate (reject mode).""" -+ mock_config.return_value = "reject" # Reject action -+ -+ agent = self.create_mock_registrar_agent() -+ agent.changes = {"ekcert": self.non_compliant_wrapped_cert} -+ agent.values = {} # No old certificate -+ -+ result = agent._check_cert_compliance("ekcert") -+ self.assertFalse(result) -+ agent._add_error.assert_called_once() # Should add error in reject mode -+ -+ @patch("keylime.config.get") -+ def test_check_all_cert_compliance_no_non_compliant(self, mock_config): -+ """Test _check_all_cert_compliance when all certificates are compliant.""" -+ mock_config.return_value = "warn" -+ -+ agent = self.create_mock_registrar_agent() -+ agent.changes = { -+ "ekcert": self.compliant_wrapped_cert, -+ "iak_cert": self.compliant_wrapped_cert, -+ } -+ agent.values = {} -+ -+ # Should not raise any exceptions or log warnings -+ with patch("keylime.models.registrar.registrar_agent.logger") as mock_logger: -+ agent._check_all_cert_compliance() -+ mock_logger.warning.assert_not_called() -+ mock_logger.error.assert_not_called() -+ -+ @patch("keylime.config.get") -+ def test_check_all_cert_compliance_with_non_compliant_warn(self, mock_config): -+ """Test _check_all_cert_compliance with non-compliant certificates (warn mode).""" -+ mock_config.return_value = "warn" -+ -+ agent = self.create_mock_registrar_agent() -+ agent.changes = { -+ "ekcert": self.non_compliant_wrapped_cert, -+ "iak_cert": self.compliant_wrapped_cert, -+ "idevid_cert": self.non_compliant_wrapped_cert, -+ } -+ agent.values = {} -+ -+ with patch("keylime.models.registrar.registrar_agent.logger") as mock_logger: -+ agent._check_all_cert_compliance() -+ # Should log warning for non-compliant certificates -+ mock_logger.warning.assert_called_once() -+ format_string = mock_logger.warning.call_args[0][0] -+ cert_names = mock_logger.warning.call_args[0][1] -+ self.assertIn("Certificate(s) %s may not conform", format_string) -+ self.assertEqual("'ekcert' and 'idevid_cert'", cert_names) -+ -+ @patch("keylime.config.get") -+ def test_check_all_cert_compliance_with_non_compliant_reject(self, mock_config): -+ """Test _check_all_cert_compliance with non-compliant certificates (reject mode).""" -+ mock_config.return_value = "reject" -+ -+ agent = self.create_mock_registrar_agent() -+ agent.changes = { -+ "ekcert": self.non_compliant_wrapped_cert, -+ "mtls_cert": self.non_compliant_wrapped_cert, -+ } -+ agent.values = {} -+ -+ with patch("keylime.models.registrar.registrar_agent.logger") as mock_logger: -+ agent._check_all_cert_compliance() -+ # Should log error for non-compliant certificates -+ mock_logger.error.assert_called_once() -+ format_string = mock_logger.error.call_args[0][0] -+ cert_names = mock_logger.error.call_args[0][1] -+ self.assertIn("Certificate(s) %s may not conform", format_string) -+ self.assertIn("were rejected due to config", format_string) -+ self.assertEqual("'ekcert' and 'mtls_cert'", cert_names) -+ -+ @patch("keylime.config.get") -+ def test_check_all_cert_compliance_ignore_mode(self, mock_config): -+ """Test _check_all_cert_compliance with ignore mode.""" -+ mock_config.return_value = "ignore" -+ -+ agent = self.create_mock_registrar_agent() -+ agent.changes = { -+ "ekcert": self.non_compliant_wrapped_cert, -+ "iak_cert": self.non_compliant_wrapped_cert, -+ } -+ agent.values = {} -+ -+ with patch("keylime.models.registrar.registrar_agent.logger") as mock_logger: -+ agent._check_all_cert_compliance() -+ # Should not log anything in ignore mode -+ mock_logger.warning.assert_not_called() -+ mock_logger.error.assert_not_called() -+ -+ def test_check_all_cert_compliance_single_non_compliant(self): -+ """Test _check_all_cert_compliance message formatting for single certificate.""" -+ agent = self.create_mock_registrar_agent() -+ agent.changes = {"ekcert": self.non_compliant_wrapped_cert} -+ agent.values = {} -+ -+ with patch("keylime.config.get", return_value="warn"): -+ with patch("keylime.models.registrar.registrar_agent.logger") as mock_logger: -+ agent._check_all_cert_compliance() -+ # Should format message correctly for single certificate -+ format_string = mock_logger.warning.call_args[0][0] -+ cert_names = mock_logger.warning.call_args[0][1] -+ self.assertIn("Certificate(s) %s may not conform", format_string) -+ self.assertEqual("'ekcert'", cert_names) -+ self.assertNotIn(" and", cert_names) # Should not have "and" for single cert -+ -+ def test_field_names_coverage(self): -+ """Test that all expected certificate field names are checked.""" -+ agent = self.create_mock_registrar_agent() -+ agent.changes = { -+ "ekcert": self.non_compliant_wrapped_cert, -+ "iak_cert": self.non_compliant_wrapped_cert, -+ "idevid_cert": self.non_compliant_wrapped_cert, -+ "mtls_cert": self.non_compliant_wrapped_cert, -+ } -+ agent.values = {} -+ -+ with patch("keylime.config.get", return_value="warn"): -+ with patch("keylime.models.registrar.registrar_agent.logger") as mock_logger: -+ agent._check_all_cert_compliance() -+ # Should check all four certificate fields -+ format_string = mock_logger.warning.call_args[0][0] -+ cert_names = mock_logger.warning.call_args[0][1] -+ self.assertIn("Certificate(s) %s may not conform", format_string) -+ expected_names = "'ekcert', 'iak_cert', 'idevid_cert' and 'mtls_cert'" -+ self.assertEqual(expected_names, cert_names) -+ -+ -+if __name__ == "__main__": -+ unittest.main() diff --git a/0014-Add-shared-memory-infrastructure-for-multiprocess-co.patch b/0014-Add-shared-memory-infrastructure-for-multiprocess-co.patch deleted file mode 100644 index 1a0b7c0..0000000 --- a/0014-Add-shared-memory-infrastructure-for-multiprocess-co.patch +++ /dev/null @@ -1,1107 +0,0 @@ -From 61a8bfb4acc28e33f5b25c54ede38fb981cdb0b4 Mon Sep 17 00:00:00 2001 -From: Sergio Correia -Date: Tue, 9 Dec 2025 11:11:43 +0000 -Subject: [PATCH 14/15] Add shared memory infrastructure for multiprocess - communication - -Backport of upstream https://github.com/keylime/keylime/pull/1817/commits/1024e19d - -Signed-off-by: Sergio Correia ---- - keylime-selinux-42.1.2/keylime.te | 2 + - keylime/cloud_verifier_tornado.py | 89 ++--- - keylime/cmd/verifier.py | 6 + - keylime/config.py | 87 +++++ - keylime/shared_data.py | 513 +++++++++++++++++++++++++ - keylime/tpm/tpm_main.py | 17 +- - keylime/web/base/default_controller.py | 6 + - test/test_shared_data.py | 199 ++++++++++ - 8 files changed, 868 insertions(+), 51 deletions(-) - create mode 100644 keylime/shared_data.py - create mode 100644 test/test_shared_data.py - -diff --git a/keylime-selinux-42.1.2/keylime.te b/keylime-selinux-42.1.2/keylime.te -index 2c6a59e..8b8a615 100644 ---- a/keylime-selinux-42.1.2/keylime.te -+++ b/keylime-selinux-42.1.2/keylime.te -@@ -77,6 +77,8 @@ optional_policy(` - allow keylime_server_t self:key { create read setattr view write }; - allow keylime_server_t self:netlink_route_socket { create_stream_socket_perms nlmsg_read }; - allow keylime_server_t self:udp_socket create_stream_socket_perms; -+allow keylime_server_t keylime_tmp_t:sock_file { create write }; -+allow keylime_server_t self:unix_stream_socket connectto; - - fs_dontaudit_search_cgroup_dirs(keylime_server_t) - -diff --git a/keylime/cloud_verifier_tornado.py b/keylime/cloud_verifier_tornado.py -index 89aa703..67ba8af 100644 ---- a/keylime/cloud_verifier_tornado.py -+++ b/keylime/cloud_verifier_tornado.py -@@ -6,8 +6,8 @@ import signal - import sys - import traceback - from concurrent.futures import ThreadPoolExecutor --from multiprocessing import Process - from contextlib import contextmanager -+from multiprocessing import Process - from typing import Any, Dict, Iterator, List, Optional, Tuple, Union, cast - - import tornado.httpserver -@@ -27,6 +27,7 @@ from keylime import ( - json, - keylime_logging, - revocation_notifier, -+ shared_data, - signing, - tornado_requests, - web_util, -@@ -43,7 +44,6 @@ from keylime.mba import mba - - logger = keylime_logging.init_logging("verifier") - --GLOBAL_POLICY_CACHE: Dict[str, Dict[str, str]] = {} - - set_severity_config(config.getlist("verifier", "severity_labels"), config.getlist("verifier", "severity_policy")) - -@@ -140,44 +140,41 @@ def _from_db_obj(agent_db_obj: VerfierMain) -> Dict[str, Any]: - return agent_dict - - --def verifier_read_policy_from_cache(ima_policy_data: Dict[str, str]) -> str: -- checksum = ima_policy_data.get("checksum", "") -- name = ima_policy_data.get("name", "empty") -- agent_id = ima_policy_data.get("agent_id", "") -+def verifier_read_policy_from_cache(stored_agent: VerfierMain) -> str: -+ checksum = "" -+ name = "empty" -+ agent_id = str(stored_agent.agent_id) - -- if not agent_id: -- return "" -+ # Initialize agent policy cache if it doesn't exist -+ shared_data.initialize_agent_policy_cache(agent_id) - -- if agent_id not in GLOBAL_POLICY_CACHE: -- GLOBAL_POLICY_CACHE[agent_id] = {} -- GLOBAL_POLICY_CACHE[agent_id][""] = "" -+ if stored_agent.ima_policy: -+ checksum = str(stored_agent.ima_policy.checksum) -+ name = stored_agent.ima_policy.name - -- if checksum not in GLOBAL_POLICY_CACHE[agent_id]: -- if len(GLOBAL_POLICY_CACHE[agent_id]) > 1: -- # Perform a cleanup of the contents, IMA policy checksum changed -- logger.debug( -- "Cleaning up policy cache for policy named %s, with checksum %s, used by agent %s", -- name, -- checksum, -- agent_id, -- ) -+ # Check if policy is already cached -+ cached_policy = shared_data.get_cached_policy(agent_id, checksum) -+ if cached_policy is not None: -+ return cached_policy - -- GLOBAL_POLICY_CACHE[agent_id] = {} -- GLOBAL_POLICY_CACHE[agent_id][""] = "" -+ # Policy not cached, need to clean up and load from database -+ shared_data.cleanup_agent_policy_cache(agent_id, checksum) - -- logger.debug( -- "IMA policy named %s, with checksum %s, used by agent %s is not present on policy cache on this verifier, performing SQLAlchemy load", -- name, -- checksum, -- agent_id, -- ) -+ logger.debug( -+ "IMA policy named %s, with checksum %s, used by agent %s is not present on policy cache on this verifier, performing SQLAlchemy load", -+ name, -+ checksum, -+ agent_id, -+ ) - -- # Get the large ima_policy content - it's already loaded in ima_policy_data -- ima_policy = ima_policy_data.get("ima_policy", "") -- assert isinstance(ima_policy, str) -- GLOBAL_POLICY_CACHE[agent_id][checksum] = ima_policy -+ # Actually contacts the database and load the (large) ima_policy column for "allowlists" table -+ ima_policy = stored_agent.ima_policy.ima_policy -+ assert isinstance(ima_policy, str) - -- return GLOBAL_POLICY_CACHE[agent_id][checksum] -+ # Cache the policy for future use -+ shared_data.cache_policy(agent_id, checksum, ima_policy) -+ -+ return ima_policy - - - def verifier_db_delete_agent(session: Session, agent_id: str) -> None: -@@ -475,12 +472,11 @@ class AgentsHandler(BaseHandler): - return - - # Cleanup the cache when the agent is deleted. Do it early. -- if agent_id in GLOBAL_POLICY_CACHE: -- del GLOBAL_POLICY_CACHE[agent_id] -- logger.debug( -- "Cleaned up policy cache from all entries used by agent %s", -- agent_id, -- ) -+ shared_data.clear_agent_policy_cache(agent_id) -+ logger.debug( -+ "Cleaned up policy cache from all entries used by agent %s", -+ agent_id, -+ ) - - op_state = agent.operational_state - if op_state in (states.SAVED, states.FAILED, states.TERMINATED, states.TENANT_FAILED, states.INVALID_QUOTE): -@@ -1763,7 +1759,6 @@ async def process_agent( - stored_agent = None - - # First database operation - read agent data and extract all needed data within session context -- ima_policy_data = {} - mb_policy_data = None - with session_context() as session: - try: -@@ -1779,15 +1774,6 @@ async def process_agent( - .first() - ) - -- # Extract IMA policy data within session context to avoid DetachedInstanceError -- if stored_agent and stored_agent.ima_policy: -- ima_policy_data = { -- "checksum": str(stored_agent.ima_policy.checksum), -- "name": stored_agent.ima_policy.name, -- "agent_id": str(stored_agent.agent_id), -- "ima_policy": stored_agent.ima_policy.ima_policy, # Extract the large content too -- } -- - # Extract MB policy data within session context - if stored_agent and stored_agent.mb_policy: - mb_policy_data = stored_agent.mb_policy.mb_policy -@@ -1869,7 +1855,10 @@ async def process_agent( - logger.error("SQLAlchemy Error for agent ID %s: %s", agent["agent_id"], e) - - # Load agent's IMA policy -- runtime_policy = verifier_read_policy_from_cache(ima_policy_data) -+ if stored_agent: -+ runtime_policy = verifier_read_policy_from_cache(stored_agent) -+ else: -+ runtime_policy = "" - - # Get agent's measured boot policy - mb_policy = mb_policy_data -diff --git a/keylime/cmd/verifier.py b/keylime/cmd/verifier.py -index f3e1a86..1f9f4e5 100644 ---- a/keylime/cmd/verifier.py -+++ b/keylime/cmd/verifier.py -@@ -1,6 +1,7 @@ - from keylime import cloud_verifier_tornado, config, keylime_logging - from keylime.common.migrations import apply - from keylime.mba import mba -+from keylime.shared_data import initialize_shared_memory - - logger = keylime_logging.init_logging("verifier") - -@@ -10,6 +11,11 @@ def main() -> None: - if config.has_option("verifier", "auto_migrate_db") and config.getboolean("verifier", "auto_migrate_db"): - apply("cloud_verifier") - -+ # Initialize shared memory BEFORE creating server instance -+ # This MUST happen before verifier instantiation and worker forking -+ logger.info("Initializing shared memory manager in main process before server creation") -+ initialize_shared_memory() -+ - # Explicitly load and initialize measured boot components - mba.load_imports() - cloud_verifier_tornado.main() -diff --git a/keylime/config.py b/keylime/config.py -index e7ac634..b5cd546 100644 ---- a/keylime/config.py -+++ b/keylime/config.py -@@ -114,6 +114,85 @@ if "KEYLIME_LOGGING_CONFIG" in os.environ: - _config: Optional[Dict[str, RawConfigParser]] = None - - -+def _check_file_permissions(component: str, file_path: str) -> bool: -+ """Check if a config file has correct permissions and is readable. -+ -+ Args: -+ component: The component name (e.g., 'verifier', 'agent') -+ file_path: Path to the config file -+ -+ Returns: -+ True if file is readable, False otherwise -+ """ -+ if not os.path.exists(file_path): -+ return False -+ -+ if not os.access(file_path, os.R_OK): -+ import grp # pylint: disable=import-outside-toplevel -+ import pwd # pylint: disable=import-outside-toplevel -+ import stat # pylint: disable=import-outside-toplevel -+ -+ try: -+ file_stat = os.stat(file_path) -+ owner = pwd.getpwuid(file_stat.st_uid).pw_name -+ group = grp.getgrgid(file_stat.st_gid).gr_name -+ mode = stat.filemode(file_stat.st_mode) -+ except Exception: -+ owner = group = mode = "unknown" -+ -+ base_logger.error( # pylint: disable=logging-not-lazy -+ "=" * 80 -+ + "\n" -+ + "CRITICAL CONFIG ERROR: Config file %s exists but is not readable!\n" -+ + "File permissions: %s (owner: %s, group: %s)\n" -+ + "The keylime_%s service needs read access to this file.\n" -+ + "Fix with: chown keylime:keylime %s && chmod 440 %s\n" -+ + "=" * 80, -+ file_path, -+ mode, -+ owner, -+ group, -+ component, -+ file_path, -+ file_path, -+ ) -+ return False -+ -+ return True -+ -+ -+def _validate_config_files(component: str, file_paths: List[str], files_read: List[str]) -> None: -+ """Validate that config files were successfully parsed. -+ -+ Args: -+ component: The component name (e.g., 'verifier', 'agent') -+ file_paths: List of file paths that were attempted to be read -+ files_read: List of files that ConfigParser successfully read -+ """ -+ for file_path in file_paths: -+ # Check file permissions first -+ if not _check_file_permissions(component, file_path): -+ continue -+ -+ if file_path not in files_read: -+ base_logger.error( # pylint: disable=logging-not-lazy -+ "=" * 80 -+ + "\n" -+ + "CRITICAL CONFIG ERROR: Config file %s exists but failed to parse!\n" -+ + "This usually indicates duplicate keys within the same file.\n" -+ + "Common issues:\n" -+ + " - Same option appears multiple times in the same [%s] section\n" -+ + " - Empty values (key = ) conflicting with defined values\n" -+ + " - Invalid INI file syntax\n" -+ + "Please check the file for duplicate entries.\n" -+ + "You can validate the file with: python3 -c \"import configparser; c = configparser.RawConfigParser(); print(c.read('%s'))\"\n" -+ + "=" * 80, -+ file_path, -+ component, -+ file_path, -+ ) -+ -+ - def get_config(component: str) -> RawConfigParser: - """Find the configuration file to use for the given component and apply the - overrides defined by configuration snippets. -@@ -216,6 +295,10 @@ def get_config(component: str) -> RawConfigParser: - - # Validate that at least one config file is present - config_file = _config[component].read(c) -+ -+ # Validate the config file was parsed successfully -+ _validate_config_files(component, [c], config_file) -+ - if config_file: - base_logger.info("Reading configuration from %s", config_file) - -@@ -230,6 +313,10 @@ def get_config(component: str) -> RawConfigParser: - [os.path.join(d, f) for f in os.listdir(d) if f and os.path.isfile(os.path.join(d, f))] - ) - applied_snippets = _config[component].read(snippets) -+ -+ # Validate all snippet files were parsed successfully -+ _validate_config_files(component, snippets, applied_snippets) -+ - if applied_snippets: - base_logger.info("Applied configuration snippets from %s", d) - -diff --git a/keylime/shared_data.py b/keylime/shared_data.py -new file mode 100644 -index 0000000..23a3d81 ---- /dev/null -+++ b/keylime/shared_data.py -@@ -0,0 +1,513 @@ -+"""Shared memory management for keylime multiprocess applications. -+ -+This module provides thread-safe shared data management between processes -+using multiprocessing.Manager(). -+""" -+ -+import atexit -+import multiprocessing as mp -+import threading -+import time -+from typing import Any, Dict, List, Optional -+ -+from keylime import keylime_logging -+ -+logger = keylime_logging.init_logging("shared_data") -+ -+ -+class FlatDictView: -+ """A dictionary-like view over a flat key-value store. -+ -+ This class provides dict-like access to a subset of keys in a flat store, -+ identified by a namespace prefix. This avoids the nested DictProxy issues. -+ -+ Example: -+ store = manager.dict() # Flat store -+ view = FlatDictView(store, lock, "sessions") -+ view["123"] = "data" # Stores as "dict:sessions:123" in flat store -+ val = view["123"] # Retrieves from "dict:sessions:123" -+ """ -+ -+ def __init__(self, store: Any, lock: Any, namespace: str) -> None: -+ self._store = store -+ self._lock = lock -+ self._namespace = namespace -+ -+ def _make_key(self, key: Any) -> str: -+ """Convert user key to internal flat key with namespace prefix.""" -+ return f"dict:{self._namespace}:{key}" -+ -+ def __getitem__(self, key: Any) -> Any: -+ with self._lock: -+ return self._store[self._make_key(key)] -+ -+ def __setitem__(self, key: Any, value: Any) -> None: -+ flat_key = self._make_key(key) -+ with self._lock: -+ self._store[flat_key] = value -+ -+ def __delitem__(self, key: Any) -> None: -+ flat_key = self._make_key(key) -+ with self._lock: -+ del self._store[flat_key] -+ -+ def __contains__(self, key: Any) -> bool: -+ return self._make_key(key) in self._store -+ -+ def get(self, key: Any, default: Any = None) -> Any: -+ with self._lock: -+ return self._store.get(self._make_key(key), default) -+ -+ def keys(self) -> List[Any]: -+ """Return keys in this namespace.""" -+ prefix = f"dict:{self._namespace}:" -+ all_store_keys = list(self._store.keys()) -+ matching_keys = [k[len(prefix) :] for k in all_store_keys if k.startswith(prefix)] -+ return matching_keys -+ -+ def values(self) -> List[Any]: -+ """Return values in this namespace.""" -+ prefix = f"dict:{self._namespace}:" -+ with self._lock: -+ return [v for k, v in self._store.items() if k.startswith(prefix)] -+ -+ def items(self) -> List[tuple[Any, Any]]: -+ """Return (key, value) pairs in this namespace.""" -+ prefix = f"dict:{self._namespace}:" -+ with self._lock: -+ result = [(k[len(prefix) :], v) for k, v in self._store.items() if k.startswith(prefix)] -+ return result -+ -+ def __len__(self) -> int: -+ """Return number of items in this namespace.""" -+ return len(self.keys()) -+ -+ def __repr__(self) -> str: -+ return f"FlatDictView({self._namespace}, {len(self)} items)" -+ -+ -+class SharedDataManager: -+ """Thread-safe shared data manager for multiprocess applications. -+ -+ This class uses multiprocessing.Manager() to create proxy objects that can -+ be safely accessed from multiple processes. All data stored must be pickleable. -+ -+ Example: -+ manager = SharedDataManager() -+ -+ # Store simple data -+ manager.set_data("config_value", "some_config") -+ value = manager.get_data("config_value") -+ -+ # Work with shared dictionaries -+ agent_cache = manager.get_or_create_dict("agent_cache") -+ agent_cache["agent_123"] = {"last_seen": time.time()} -+ -+ # Work with shared lists -+ event_log = manager.get_or_create_list("events") -+ event_log.append({"type": "attestation", "agent": "agent_123"}) -+ """ -+ -+ def __init__(self) -> None: -+ """Initialize the shared data manager. -+ -+ This must be called before any process forking occurs to ensure -+ all child processes inherit access to the shared data. -+ """ -+ logger.debug("Initializing SharedDataManager") -+ -+ # Use explicit context to ensure fork compatibility -+ # The Manager must be started BEFORE any fork() calls -+ ctx = mp.get_context("fork") -+ self._manager = ctx.Manager() -+ -+ # CRITICAL FIX: Use a SINGLE flat dict instead of nested dicts -+ # Nested DictProxy objects have synchronization issues -+ # We'll use key prefixes like "dict:auth_sessions:session_id" instead -+ self._store = self._manager.dict() # Single flat store for all data -+ self._lock = self._manager.Lock() -+ self._initialized_at = time.time() -+ -+ # Register handler to reinitialize manager connection after fork -+ # This is needed because Manager uses network connections that don't survive fork -+ try: -+ import os # pylint: disable=import-outside-toplevel -+ -+ self._parent_pid = os.getpid() -+ logger.debug("SharedDataManager initialized in process %d", self._parent_pid) -+ except Exception as e: -+ logger.warning("Could not register PID tracking: %s", e) -+ -+ # Ensure cleanup on exit -+ atexit.register(self.cleanup) -+ -+ logger.info("SharedDataManager initialized successfully") -+ -+ def set_data(self, key: str, value: Any) -> None: -+ """Store arbitrary pickleable data by key. -+ -+ Args: -+ key: Unique identifier for the data -+ value: Any pickleable Python object -+ -+ Raises: -+ TypeError: If value is not pickleable -+ """ -+ with self._lock: -+ try: -+ self._store[key] = value -+ logger.debug("Stored data for key: %s", key) -+ except Exception as e: -+ logger.error("Failed to store data for key '%s': %s", key, e) -+ raise -+ -+ def get_data(self, key: str, default: Any = None) -> Any: -+ """Retrieve data by key. -+ -+ Args: -+ key: The key to retrieve -+ default: Value to return if key doesn't exist -+ -+ Returns: -+ The stored value or default if key doesn't exist -+ """ -+ with self._lock: -+ value = self._store.get(key, default) -+ logger.debug("Retrieved data for key: %s (found: %s)", key, value is not default) -+ return value -+ -+ def get_or_create_dict(self, key: str) -> Dict[str, Any]: -+ """Get or create a shared dictionary. -+ -+ Args: -+ key: Unique identifier for the dictionary -+ -+ Returns: -+ A shared dictionary-like object that syncs across processes -+ -+ Note: -+ Returns a FlatDictView that uses key prefixes in the flat store -+ instead of actual nested dicts, to avoid DictProxy nesting issues. -+ """ -+ # Mark that this namespace exists -+ namespace_key = f"__namespace__{key}" -+ if namespace_key not in self._store: -+ with self._lock: -+ self._store[namespace_key] = True -+ -+ # Return a view that operates on the flat store with key prefix -+ return FlatDictView(self._store, self._lock, key) # type: ignore[return-value,no-untyped-call] -+ -+ def get_or_create_list(self, key: str) -> List[Any]: -+ """Get or create a shared list. -+ -+ Args: -+ key: Unique identifier for the list -+ -+ Returns: -+ A shared list (proxy object) that syncs across processes -+ """ -+ with self._lock: -+ if key not in self._store: -+ self._store[key] = self._manager.list() -+ logger.debug("Created new shared list for key: %s", key) -+ else: -+ logger.debug("Retrieved existing shared list for key: %s", key) -+ return self._store[key] # type: ignore[no-any-return] -+ -+ def delete_data(self, key: str) -> bool: -+ """Delete data by key. -+ -+ Args: -+ key: The key to delete -+ -+ Returns: -+ True if the key existed and was deleted, False otherwise -+ """ -+ with self._lock: -+ if key in self._store: -+ del self._store[key] -+ logger.debug("Deleted data for key: %s", key) -+ return True -+ logger.debug("Key not found for deletion: %s", key) -+ return False -+ -+ def has_key(self, key: str) -> bool: -+ """Check if a key exists. -+ -+ Args: -+ key: The key to check -+ -+ Returns: -+ True if key exists, False otherwise -+ """ -+ with self._lock: -+ return key in self._store -+ -+ def get_keys(self) -> List[str]: -+ """Get all stored keys. -+ -+ Returns: -+ List of all keys in the store -+ """ -+ with self._lock: -+ return list(self._store.keys()) -+ -+ def clear_all(self) -> None: -+ """Clear all stored data. Use with caution!""" -+ with self._lock: -+ key_count = len(self._store) -+ self._store.clear() -+ logger.warning("Cleared all shared data (%d keys)", key_count) -+ -+ def get_stats(self) -> Dict[str, Any]: -+ """Get statistics about stored data. -+ -+ Returns: -+ Dictionary containing storage statistics -+ """ -+ with self._lock: -+ return { -+ "total_keys": len(self._store), -+ "initialized_at": self._initialized_at, -+ "uptime_seconds": time.time() - self._initialized_at, -+ } -+ -+ def cleanup(self) -> None: -+ """Cleanup shared resources. -+ -+ This is automatically called on exit but can be called manually -+ for explicit cleanup. -+ """ -+ if hasattr(self, "_manager"): -+ logger.debug("Shutting down SharedDataManager") -+ try: -+ self._manager.shutdown() -+ logger.info("SharedDataManager shutdown complete") -+ except Exception as e: -+ logger.error("Error during SharedDataManager shutdown: %s", e) -+ -+ def __repr__(self) -> str: -+ stats = self.get_stats() -+ return f"SharedDataManager(keys={stats['total_keys']}, " f"uptime={stats['uptime_seconds']:.1f}s)" -+ -+ @property -+ def manager(self) -> Any: # type: ignore[misc] -+ """Access to the underlying multiprocessing Manager for advanced usage.""" -+ return self._manager -+ -+ -+# Global shared memory manager instance -+_global_shared_manager: Optional[SharedDataManager] = None -+_manager_lock = threading.Lock() -+ -+ -+def initialize_shared_memory() -> SharedDataManager: -+ """Initialize the global shared memory manager. -+ -+ This function MUST be called before any process forking occurs to ensure -+ all child processes share the same manager instance. -+ -+ For tornado/multiprocess servers, call this before starting workers. -+ -+ Returns: -+ SharedDataManager: The global shared memory manager instance -+ -+ Raises: -+ RuntimeError: If called after manager is already initialized -+ """ -+ global _global_shared_manager -+ -+ with _manager_lock: -+ if _global_shared_manager is not None: -+ logger.warning("Shared memory manager already initialized, returning existing instance") -+ return _global_shared_manager -+ -+ logger.info("Initializing global shared memory manager") -+ _global_shared_manager = SharedDataManager() -+ logger.info("Global shared memory manager initialized") -+ -+ return _global_shared_manager -+ -+ -+def get_shared_memory() -> SharedDataManager: -+ """Get the global shared memory manager instance. -+ -+ This function returns a singleton SharedDataManager that can be used -+ throughout keylime for caching and inter-process communication. -+ -+ The manager is automatically initialized on first access and cleaned up -+ on process exit. -+ -+ IMPORTANT: In multiprocess applications (like tornado with workers), -+ you MUST call initialize_shared_memory() BEFORE forking workers. -+ Otherwise each worker will get its own separate manager. -+ -+ Returns: -+ SharedDataManager: The global shared memory manager instance -+ """ -+ global _global_shared_manager -+ -+ if _global_shared_manager is None: -+ with _manager_lock: -+ if _global_shared_manager is None: -+ logger.info("Initializing global shared memory manager") -+ _global_shared_manager = SharedDataManager() # type: ignore[no-untyped-call] -+ logger.info("Global shared memory manager initialized") -+ -+ return _global_shared_manager -+ -+ -+def cleanup_global_shared_memory() -> None: -+ """Cleanup the global shared memory manager. -+ -+ This is automatically called on exit but can be called manually. -+ """ -+ global _global_shared_manager -+ -+ if _global_shared_manager is not None: -+ logger.info("Cleaning up global shared memory manager") -+ _global_shared_manager.cleanup() -+ _global_shared_manager = None -+ -+ -+# Convenience functions for common keylime patterns -+ -+ -+def cache_policy(agent_id: str, checksum: str, policy: str) -> None: -+ """Cache a policy in shared memory. -+ -+ Args: -+ agent_id: The agent identifier -+ checksum: The policy checksum -+ policy: The policy content to cache -+ """ -+ manager = get_shared_memory() -+ policy_cache = manager.get_or_create_dict("policy_cache") -+ -+ if agent_id not in policy_cache: -+ policy_cache[agent_id] = manager.manager.dict() # type: ignore[attr-defined] -+ -+ policy_cache[agent_id][checksum] = policy -+ logger.debug("Cached policy for agent %s with checksum %s", agent_id, checksum) -+ -+ -+def get_cached_policy(agent_id: str, checksum: str) -> Optional[str]: -+ """Retrieve cached policy. -+ -+ Args: -+ agent_id: The agent identifier -+ checksum: The policy checksum -+ -+ Returns: -+ The cached policy content or None if not found -+ """ -+ manager = get_shared_memory() -+ policy_cache = manager.get_or_create_dict("policy_cache") -+ agent_policies = policy_cache.get(agent_id, {}) -+ -+ result = agent_policies.get(checksum) -+ if result: -+ logger.debug("Found cached policy for agent %s with checksum %s", agent_id, checksum) -+ else: -+ logger.debug("No cached policy found for agent %s with checksum %s", agent_id, checksum) -+ -+ return result # type: ignore[no-any-return] -+ -+ -+def clear_agent_policy_cache(agent_id: str) -> None: -+ """Clear all cached policies for an agent. -+ -+ Args: -+ agent_id: The agent identifier -+ """ -+ manager = get_shared_memory() -+ policy_cache = manager.get_or_create_dict("policy_cache") -+ -+ if agent_id in policy_cache: -+ del policy_cache[agent_id] -+ logger.debug("Cleared policy cache for agent %s", agent_id) -+ -+ -+def cleanup_agent_policy_cache(agent_id: str, keep_checksum: str = "") -> None: -+ """Clean up agent policy cache, keeping only the specified checksum. -+ -+ This mimics the cleanup behavior from GLOBAL_POLICY_CACHE where when -+ a new policy checksum is encountered, old cached policies are removed. -+ -+ Args: -+ agent_id: The agent identifier -+ keep_checksum: The checksum to keep in the cache (empty string by default) -+ """ -+ manager = get_shared_memory() -+ policy_cache = manager.get_or_create_dict("policy_cache") -+ -+ if agent_id in policy_cache and len(policy_cache[agent_id]) > 1: -+ # Keep only the empty entry and the specified checksum -+ old_policies = dict(policy_cache[agent_id]) -+ policy_cache[agent_id] = manager.manager.dict() -+ -+ # Always keep the empty entry -+ policy_cache[agent_id][""] = old_policies.get("", "") -+ -+ # Keep the specified checksum if it exists and is not empty -+ if keep_checksum and keep_checksum in old_policies: -+ policy_cache[agent_id][keep_checksum] = old_policies[keep_checksum] -+ -+ logger.debug("Cleaned up policy cache for agent %s, keeping checksum %s", agent_id, keep_checksum) -+ -+ -+def initialize_agent_policy_cache(agent_id: str) -> Dict[str, Any]: -+ """Initialize policy cache for an agent if it doesn't exist. -+ -+ Args: -+ agent_id: The agent identifier -+ -+ Returns: -+ The agent's policy cache dictionary -+ """ -+ manager = get_shared_memory() -+ policy_cache = manager.get_or_create_dict("policy_cache") -+ -+ if agent_id not in policy_cache: -+ policy_cache[agent_id] = manager.manager.dict() # type: ignore[attr-defined] -+ policy_cache[agent_id][""] = "" -+ logger.debug("Initialized policy cache for agent %s", agent_id) -+ -+ return policy_cache[agent_id] # type: ignore[no-any-return] -+ -+ -+def get_agent_cache(agent_id: str) -> Dict[str, Any]: -+ """Get shared cache for a specific agent. -+ -+ Args: -+ agent_id: The agent identifier -+ -+ Returns: -+ A shared dictionary for caching agent-specific data -+ """ -+ manager = get_shared_memory() -+ return manager.get_or_create_dict(f"agent_cache:{agent_id}") -+ -+ -+def get_verification_queue(agent_id: str) -> List[Any]: -+ """Get verification queue for batching database operations. -+ -+ Args: -+ agent_id: The agent identifier -+ -+ Returns: -+ A shared list for queuing verification operations -+ """ -+ manager = get_shared_memory() -+ return manager.get_or_create_list(f"verification_queue:{agent_id}") -+ -+ -+def get_shared_stats() -> Dict[str, Any]: -+ """Get statistics about shared memory usage. -+ -+ Returns: -+ Dictionary containing storage statistics -+ """ -+ manager = get_shared_memory() -+ return manager.get_stats() -diff --git a/keylime/tpm/tpm_main.py b/keylime/tpm/tpm_main.py -index 6f2e89f..9b54fc3 100644 ---- a/keylime/tpm/tpm_main.py -+++ b/keylime/tpm/tpm_main.py -@@ -10,7 +10,7 @@ from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey - - from keylime import cert_utils, config, json, keylime_logging - from keylime.agentstates import AgentAttestState, TPMClockInfo --from keylime.common.algorithms import Hash -+from keylime.common.algorithms import Hash, Sign - from keylime.failure import Component, Failure - from keylime.ima import ima - from keylime.ima.file_signatures import ImaKeyrings -@@ -50,6 +50,21 @@ class Tpm: - - return (keyblob, key) - -+ # Mapping from keylime.common.algorithms enums to TPM algorithm constants -+ # Used for validating that TPM attestations use expected cryptographic algorithms -+ HASH_ALG_TO_TPM = { -+ Hash.SHA1: tpm2_objects.TPM_ALG_SHA1, -+ Hash.SHA256: tpm2_objects.TPM_ALG_SHA256, -+ Hash.SHA384: tpm2_objects.TPM_ALG_SHA384, -+ Hash.SHA512: tpm2_objects.TPM_ALG_SHA512, -+ } -+ -+ SIGN_ALG_TO_TPM = { -+ Sign.RSASSA: tpm2_objects.TPM_ALG_RSASSA, -+ Sign.RSAPSS: tpm2_objects.TPM_ALG_RSAPSS, -+ Sign.ECDSA: tpm2_objects.TPM_ALG_ECDSA, -+ } -+ - @staticmethod - def verify_aik_with_iak(uuid: str, aik_tpm: bytes, iak_tpm: bytes, iak_attest: bytes, iak_sign: bytes) -> bool: - attest_body = iak_attest.split(b"\x00$")[1] -diff --git a/keylime/web/base/default_controller.py b/keylime/web/base/default_controller.py -index 971ed06..ba0782e 100644 ---- a/keylime/web/base/default_controller.py -+++ b/keylime/web/base/default_controller.py -@@ -19,6 +19,12 @@ class DefaultController(Controller): - self.send_response(400, "Bad Request") - - def malformed_params(self, **_params: Any) -> None: -+ import traceback # pylint: disable=import-outside-toplevel -+ -+ from keylime import keylime_logging # pylint: disable=import-outside-toplevel -+ -+ logger = keylime_logging.init_logging("web") -+ logger.error("Malformed params error. Traceback: %s", traceback.format_exc()) - self.send_response(400, "Malformed Request Parameter") - - def action_dispatch_error(self, **_param: Any) -> None: -diff --git a/test/test_shared_data.py b/test/test_shared_data.py -new file mode 100644 -index 0000000..8de7e64 ---- /dev/null -+++ b/test/test_shared_data.py -@@ -0,0 +1,199 @@ -+"""Unit tests for shared memory infrastructure.""" -+ -+import unittest -+ -+from keylime.shared_data import ( -+ SharedDataManager, -+ cache_policy, -+ cleanup_agent_policy_cache, -+ cleanup_global_shared_memory, -+ clear_agent_policy_cache, -+ get_cached_policy, -+ get_shared_memory, -+ initialize_agent_policy_cache, -+) -+ -+ -+class TestSharedDataManager(unittest.TestCase): -+ """Test cases for SharedDataManager class.""" -+ -+ def setUp(self): -+ """Set up test fixtures.""" -+ self.manager = SharedDataManager() -+ -+ def tearDown(self): -+ """Clean up after tests.""" -+ if self.manager: -+ self.manager.cleanup() -+ -+ def test_set_and_get_data(self): -+ """Test basic set and get operations.""" -+ self.manager.set_data("test_key", "test_value") -+ result = self.manager.get_data("test_key") -+ self.assertEqual(result, "test_value") -+ -+ def test_get_nonexistent_data(self): -+ """Test getting data that doesn't exist returns None.""" -+ result = self.manager.get_data("nonexistent_key") -+ self.assertIsNone(result) -+ -+ def test_get_data_with_default(self): -+ """Test getting data with default value.""" -+ result = self.manager.get_data("nonexistent_key", default="default_value") -+ self.assertEqual(result, "default_value") -+ -+ def test_delete_data(self): -+ """Test deleting data.""" -+ self.manager.set_data("test_key", "test_value") -+ result = self.manager.delete_data("test_key") -+ self.assertTrue(result) -+ -+ # Verify it's actually deleted -+ self.assertIsNone(self.manager.get_data("test_key")) -+ -+ def test_delete_nonexistent_data(self): -+ """Test deleting data that doesn't exist returns False.""" -+ result = self.manager.delete_data("nonexistent_key") -+ self.assertFalse(result) -+ -+ def test_has_key(self): -+ """Test checking if key exists.""" -+ self.manager.set_data("test_key", "test_value") -+ self.assertTrue(self.manager.has_key("test_key")) -+ self.assertFalse(self.manager.has_key("nonexistent_key")) -+ -+ def test_get_or_create_dict(self): -+ """Test getting or creating a shared dictionary.""" -+ shared_dict = self.manager.get_or_create_dict("test_dict") -+ shared_dict["key1"] = "value1" -+ shared_dict["key2"] = "value2" -+ -+ # Retrieve the same dict -+ retrieved_dict = self.manager.get_or_create_dict("test_dict") -+ self.assertEqual(retrieved_dict["key1"], "value1") -+ self.assertEqual(retrieved_dict["key2"], "value2") -+ -+ def test_get_or_create_list(self): -+ """Test getting or creating a shared list.""" -+ shared_list = self.manager.get_or_create_list("test_list") -+ shared_list.append("item1") -+ shared_list.append("item2") -+ -+ # Retrieve the same list -+ retrieved_list = self.manager.get_or_create_list("test_list") -+ self.assertEqual(len(retrieved_list), 2) -+ self.assertEqual(retrieved_list[0], "item1") -+ self.assertEqual(retrieved_list[1], "item2") -+ -+ def test_get_stats(self): -+ """Test getting manager statistics.""" -+ self.manager.set_data("key1", "value1") -+ self.manager.set_data("key2", "value2") -+ -+ stats = self.manager.get_stats() -+ self.assertIn("total_keys", stats) -+ self.assertIn("uptime_seconds", stats) -+ self.assertEqual(stats["total_keys"], 2) -+ self.assertGreaterEqual(stats["uptime_seconds"], 0) -+ -+ -+class TestPolicyCacheFunctions(unittest.TestCase): -+ """Test cases for policy cache functions.""" -+ -+ def setUp(self): -+ """Set up test fixtures.""" -+ # Get the global shared memory manager -+ self.manager = get_shared_memory() -+ -+ def tearDown(self): -+ """Clean up after tests.""" -+ # Clean up global shared memory -+ cleanup_global_shared_memory() -+ -+ def test_initialize_agent_policy_cache(self): -+ """Test initializing agent policy cache.""" -+ agent_id = "test_agent_123" -+ initialize_agent_policy_cache(agent_id) -+ -+ # Verify the cache was initialized -+ policy_cache = self.manager.get_or_create_dict("policy_cache") -+ self.assertIn(agent_id, policy_cache) -+ -+ def test_cache_and_get_policy(self): -+ """Test caching and retrieving a policy.""" -+ agent_id = "test_agent_123" -+ checksum = "abc123def456" -+ policy_content = '{"policy": "test_policy_content"}' -+ -+ # Initialize and cache policy -+ initialize_agent_policy_cache(agent_id) -+ cache_policy(agent_id, checksum, policy_content) -+ -+ # Retrieve cached policy -+ cached = get_cached_policy(agent_id, checksum) -+ self.assertEqual(cached, policy_content) -+ -+ def test_get_nonexistent_cached_policy(self): -+ """Test getting a policy that hasn't been cached.""" -+ agent_id = "test_agent_123" -+ checksum = "nonexistent_checksum" -+ -+ initialize_agent_policy_cache(agent_id) -+ cached = get_cached_policy(agent_id, checksum) -+ self.assertIsNone(cached) -+ -+ def test_clear_agent_policy_cache(self): -+ """Test clearing an agent's policy cache.""" -+ agent_id = "test_agent_123" -+ checksum = "abc123def456" -+ policy_content = '{"policy": "test_policy_content"}' -+ -+ # Initialize, cache, and then clear -+ initialize_agent_policy_cache(agent_id) -+ cache_policy(agent_id, checksum, policy_content) -+ clear_agent_policy_cache(agent_id) -+ -+ # Verify it's cleared -+ cached = get_cached_policy(agent_id, checksum) -+ self.assertIsNone(cached) -+ -+ def test_cleanup_agent_policy_cache(self): -+ """Test cleaning up old policy checksums.""" -+ agent_id = "test_agent_123" -+ old_checksum = "old_checksum" -+ new_checksum = "new_checksum" -+ policy_content = '{"policy": "test"}' -+ -+ # Initialize and cache multiple policies -+ initialize_agent_policy_cache(agent_id) -+ cache_policy(agent_id, old_checksum, policy_content) -+ cache_policy(agent_id, new_checksum, policy_content) -+ -+ # Cleanup old checksums (keeping only new_checksum) -+ cleanup_agent_policy_cache(agent_id, new_checksum) -+ -+ # Verify old checksum is removed but new one remains -+ self.assertIsNone(get_cached_policy(agent_id, old_checksum)) -+ self.assertEqual(get_cached_policy(agent_id, new_checksum), policy_content) -+ -+ def test_cache_multiple_agents(self): -+ """Test caching policies for multiple agents.""" -+ agent1 = "agent_1" -+ agent2 = "agent_2" -+ checksum = "same_checksum" -+ policy1 = '{"policy": "agent1_policy"}' -+ policy2 = '{"policy": "agent2_policy"}' -+ -+ # Cache policies for different agents -+ initialize_agent_policy_cache(agent1) -+ initialize_agent_policy_cache(agent2) -+ cache_policy(agent1, checksum, policy1) -+ cache_policy(agent2, checksum, policy2) -+ -+ # Verify each agent has its own policy -+ self.assertEqual(get_cached_policy(agent1, checksum), policy1) -+ self.assertEqual(get_cached_policy(agent2, checksum), policy2) -+ -+ -+if __name__ == "__main__": -+ unittest.main() --- -2.47.3 - diff --git a/0015-Fix-registrar-duplicate-UUID-vulnerability.patch b/0015-Fix-registrar-duplicate-UUID-vulnerability.patch deleted file mode 100644 index 2a18765..0000000 --- a/0015-Fix-registrar-duplicate-UUID-vulnerability.patch +++ /dev/null @@ -1,1188 +0,0 @@ -From fcf665cf4770c0fceb6cad8bee3533e3b82271c7 Mon Sep 17 00:00:00 2001 -From: Sergio Correia -Date: Tue, 9 Dec 2025 12:12:22 +0000 -Subject: [PATCH 15/15] Fix registrar duplicate UUID vulnerability - -Backport upstream PR#1825 - -Signed-off-by: Sergio Correia ---- - keylime/cmd/registrar.py | 6 + - keylime/models/registrar/registrar_agent.py | 116 +++++ - keylime/shared_data.py | 6 + - keylime/web/registrar/agents_controller.py | 98 +++- - test/test_agents_controller.py | 513 ++++++++++++++++++++ - test/test_registrar_tpm_identity.py | 342 +++++++++++++ - 6 files changed, 1071 insertions(+), 10 deletions(-) - create mode 100644 test/test_agents_controller.py - create mode 100644 test/test_registrar_tpm_identity.py - -diff --git a/keylime/cmd/registrar.py b/keylime/cmd/registrar.py -index 584275a..2e2b25e 100644 ---- a/keylime/cmd/registrar.py -+++ b/keylime/cmd/registrar.py -@@ -5,6 +5,7 @@ import cryptography - from keylime import config, keylime_logging - from keylime.common.migrations import apply - from keylime.models import da_manager, db_manager -+from keylime.shared_data import initialize_shared_memory - from keylime.web import RegistrarServer - - logger = keylime_logging.init_logging("registrar") -@@ -47,6 +48,11 @@ def main() -> None: - # Prepare backend for durable attestation, if configured - da_manager.make_backend("registrar") - -+ # Initialize shared memory for cross-process synchronization -+ # CRITICAL: Must be called before server.start_multi() to ensure all forked -+ # worker processes share the same manager instance for agent registration locks -+ initialize_shared_memory() -+ - # Start HTTP server - server = RegistrarServer() - server.start_multi() -diff --git a/keylime/models/registrar/registrar_agent.py b/keylime/models/registrar/registrar_agent.py -index 680316b..d071029 100644 ---- a/keylime/models/registrar/registrar_agent.py -+++ b/keylime/models/registrar/registrar_agent.py -@@ -65,8 +65,14 @@ class RegistrarAgent(PersistableModel): - def empty(cls): - agent = super().empty() - agent.provider_keys = {} -+ object.__setattr__(agent, "_tpm_identity_violation", False) - return agent - -+ @property -+ def has_tpm_identity_violation(self): -+ """Returns True if a TPM identity violation was detected during validation.""" -+ return getattr(self, "_tpm_identity_violation", False) -+ - def _check_key_against_cert(self, tpm_key_field, cert_field): - # If neither key nor certificate is being updated, no need to check - if tpm_key_field not in self.changes and cert_field not in self.changes: -@@ -139,6 +145,111 @@ class RegistrarAgent(PersistableModel): - - return compliant - -+ def _check_tpm_identity_immutable(self): -+ """ -+ Checks that TPM identity fields are not being changed during re-registration. -+ -+ This prevents an attacker from registering with the same UUID but a different TPM, -+ which would allow them to impersonate the original agent and bypass attestation. -+ -+ Checked fields (EK-based identity only): -+ - ek_tpm: Endorsement Key (primary TPM identity) -+ - ekcert: EK Certificate (binds EK to TPM manufacturer) -+ - aik_tpm: Attestation Key (bound to EK via MakeCredential/ActivateCredential) -+ -+ Note: IAK/IDevID fields are NOT checked and can change on re-registration. -+ -+ This check only applies to existing agents (those loaded from the database). -+ New agents created via RegistrarAgent.empty() have no committed values and are -+ allowed to set identity fields during initial registration. -+ -+ If the agent needs to be registered with a new TPM (e.g., hardware replacement), -+ the old agent record must be explicitly deleted first. -+ """ -+ # Define TPM identity fields that must remain immutable once set -+ # Only checking EK-based identity (ek_tpm, ekcert, aik_tpm) -+ # IAK/IDevID fields (iak_tpm, iak_cert, idevid_tpm, idevid_cert) are not checked -+ identity_fields = ["ek_tpm", "ekcert", "aik_tpm"] -+ -+ # Only check for existing agents (those loaded from database) -+ # New agents created via empty() will have no committed values -+ if not self.committed: -+ return -+ -+ # Track which fields have been changed -+ changed_fields = [] -+ -+ for field_name in identity_fields: -+ # Skip fields that are not being changed in this update -+ if field_name not in self.changes: -+ continue -+ -+ # Get the old (committed/database) and new (proposed) values -+ old_value = self.committed.get(field_name) -+ new_value = self.changes.get(field_name) -+ -+ # Allow setting a previously unset field (e.g., adding EK cert later) -+ if old_value is None: -+ continue -+ -+ # Reject attempts to remove an already-set identity field -+ if new_value is None: -+ changed_fields.append(field_name) -+ continue -+ -+ # Compare values based on field type -+ if field_name == "ekcert": -+ # For certificates, compare the actual certificate bytes -+ # Note: We compare full certificate, not just public key, because: -+ # 1. User requirement: reject if certificate changed even if same public key -+ # 2. Certificate contains more than just key (issuer, validity period, etc.) -+ # 3. Different cert for same key could indicate compromise or unauthorized replacement -+ try: -+ old_cert_bytes = old_value.public_bytes(Encoding.DER) -+ new_cert_bytes = new_value.public_bytes(Encoding.DER) -+ -+ if old_cert_bytes != new_cert_bytes: -+ changed_fields.append(field_name) -+ except Exception: -+ # If we can't extract certificate bytes, treat as changed to be safe -+ changed_fields.append(field_name) -+ else: -+ # For TPM keys (ek_tpm, aik_tpm), compare as binary data -+ # These are Binary(persist_as=String) fields, so they could be bytes or base64 strings -+ try: -+ old_bytes = old_value if isinstance(old_value, bytes) else base64.b64decode(old_value) -+ new_bytes = new_value if isinstance(new_value, bytes) else base64.b64decode(new_value) -+ -+ if old_bytes != new_bytes: -+ changed_fields.append(field_name) -+ except Exception: -+ # If comparison fails (e.g., invalid base64), treat as changed to be safe -+ changed_fields.append(field_name) -+ -+ # If any TPM identity fields were changed, this is a security violation -+ if changed_fields: -+ # Set flag to indicate TPM identity violation occurred -+ object.__setattr__(self, "_tpm_identity_violation", True) -+ -+ # Log security warning for audit trail -+ # Include agent_id and changed fields, but NOT the actual TPM values (sensitive data) -+ logger.warning( -+ "SECURITY: Rejected attempt to re-register agent '%s' with different TPM identity. " -+ "Changed fields: %s. This indicates a potential UUID spoofing attack. " -+ "The existing agent must be deleted before registering with a new TPM. " -+ "If this is unexpected, investigate for compromise.", -+ self.agent_id, -+ ", ".join(changed_fields), -+ ) -+ -+ # Add validation error to prevent registration -+ # Using "agent_id" field for the error because it's the UUID that's being improperly reused -+ self._add_error( -+ "agent_id", -+ f"cannot re-register with different TPM identity. Changed fields: {', '.join(changed_fields)}. " -+ "To register this UUID with a new TPM, delete the existing agent record first.", -+ ) -+ - def _check_all_cert_compliance(self): - non_compliant_certs = [] - -@@ -281,6 +392,11 @@ class RegistrarAgent(PersistableModel): - + ["port", "mtls_cert"], - ) - -+ # SECURITY CHECK: Verify TPM identity is not being changed on re-registration -+ # This must happen after cast_changes() (so we have new values to compare) -+ # but before other validation (so we reject immediately without processing further) -+ self._check_tpm_identity_immutable() -+ - # Log info about received EK or IAK/IDevID - self._log_root_identity() - # Verify EK as valid -diff --git a/keylime/shared_data.py b/keylime/shared_data.py -index 23a3d81..a415496 100644 ---- a/keylime/shared_data.py -+++ b/keylime/shared_data.py -@@ -58,6 +58,12 @@ class FlatDictView: - with self._lock: - return self._store.get(self._make_key(key), default) - -+ def pop(self, key: Any, default: Any = None) -> Any: -+ """Remove and return value for key, or default if key not present.""" -+ flat_key = self._make_key(key) -+ with self._lock: -+ return self._store.pop(flat_key, default) -+ - def keys(self) -> List[Any]: - """Return keys in this namespace.""" - prefix = f"dict:{self._namespace}:" -diff --git a/keylime/web/registrar/agents_controller.py b/keylime/web/registrar/agents_controller.py -index 9be2ef9..f2246de 100644 ---- a/keylime/web/registrar/agents_controller.py -+++ b/keylime/web/registrar/agents_controller.py -@@ -1,5 +1,8 @@ -+from sqlalchemy.exc import IntegrityError -+ - from keylime import keylime_logging - from keylime.models import RegistrarAgent -+from keylime.shared_data import get_shared_memory - from keylime.web.base import Controller - - logger = keylime_logging.init_logging("registrar") -@@ -28,16 +31,91 @@ class AgentsController(Controller): - - # POST /v2[.:minor]/agents/[:agent_id] - def create(self, agent_id, **params): -- agent = RegistrarAgent.get(agent_id) or RegistrarAgent.empty() # type: ignore[no-untyped-call] -- agent.update({"agent_id": agent_id, **params}) -- challenge = agent.produce_ak_challenge() -- -- if not challenge or not agent.changes_valid: -- self.log_model_errors(agent, logger) -- self.respond(400, "Could not register agent with invalid data") -- return -- -- agent.commit_changes() -+ """Register a new agent or re-register an existing agent. -+ -+ For new agents, this: -+ 1. Validates TPM identity (EK/AIK or IAK/IDevID) -+ 2. Generates an AK challenge encrypted with the EK -+ 3. Stores agent record in pending state -+ 4. Returns challenge blob to agent -+ -+ For existing agents (re-registration with same UUID): -+ 1. Verifies TPM identity has not changed (security check) -+ 2. If identity changed: rejects with 403 Forbidden -+ 3. If identity same: allows re-registration (e.g., after agent restart) -+ -+ Security: Re-registration with a different TPM is forbidden to prevent -+ UUID spoofing attacks where an attacker could impersonate a legitimate -+ agent by reusing its UUID. -+ -+ Race condition protection: Uses per-agent locks from SharedDataManager to prevent -+ race conditions between concurrent registration requests for the same agent_id. -+ This ensures the check-validate-commit sequence is atomic. Additionally, database -+ constraint violations (e.g., duplicate UUIDs from concurrent requests) are caught -+ and returned as 403 Forbidden. -+ """ -+ # Get shared memory manager and per-agent lock storage -+ shared_mem = get_shared_memory() -+ agent_locks = shared_mem.get_or_create_dict("agent_registration_locks") -+ -+ # Get or create a lock specific to this agent_id -+ if agent_id not in agent_locks: -+ agent_locks[agent_id] = shared_mem.manager.Lock() -+ -+ agent_lock = agent_locks[agent_id] -+ -+ # CRITICAL SECTION: Acquire lock to make check-validate-commit atomic -+ with agent_lock: -+ # Step 1: Load existing agent or create new one (inside lock) -+ agent = RegistrarAgent.get(agent_id) or RegistrarAgent.empty() # type: ignore[no-untyped-call] -+ -+ # Step 2: Update agent with new data and validate (inside lock) -+ agent.update({"agent_id": agent_id, **params}) -+ -+ # Step 3: Check for TPM identity change security violation -+ # Use explicit flag instead of fragile string matching for security check -+ if not agent.changes_valid and agent.has_tpm_identity_violation: -+ # Log the validation errors (includes security warning) -+ self.log_model_errors(agent, logger) -+ -+ # Return 403 Forbidden -+ # 403 indicates a policy violation, not a malformed request -+ self.respond(403, "Agent re-registration with different TPM identity is forbidden for security reasons") -+ return -+ -+ # Step 4: Generate AK challenge (inside lock) -+ challenge = agent.produce_ak_challenge() -+ -+ # Step 5: Check for any validation errors or challenge generation failure -+ if not challenge or not agent.changes_valid: -+ self.log_model_errors(agent, logger) -+ self.respond(400, "Could not register agent with invalid data") -+ return -+ -+ # Step 6: Commit to database (inside lock) -+ # This ensures no other request can modify the agent between validation and commit -+ try: -+ agent.commit_changes() -+ except IntegrityError as e: -+ # Database constraint violation - most likely duplicate agent_id -+ # This can happen if two requests try to register the same new UUID simultaneously -+ # and both pass validation before either commits (database race condition) -+ logger.warning( -+ "SECURITY: Agent registration failed due to database constraint violation for agent_id '%s'. " -+ "This UUID may already be registered by a concurrent request or the agent already exists. " -+ "Database error: %s", -+ agent_id, -+ str(e), -+ ) -+ self.respond( -+ 403, -+ f"Agent with UUID '{agent_id}' cannot be registered. " -+ "This UUID is already in use or a concurrent registration is in progress.", -+ ) -+ return -+ -+ # Lock released - safe to respond to client -+ # Return challenge blob for agent to decrypt - self.respond(200, "Success", {"blob": challenge}) - - # DELETE /v2[.:minor]/agents/:agent_id/ -diff --git a/test/test_agents_controller.py b/test/test_agents_controller.py -new file mode 100644 -index 0000000..898d8f0 ---- /dev/null -+++ b/test/test_agents_controller.py -@@ -0,0 +1,513 @@ -+"""Unit tests for AgentsController (registrar). -+ -+Tests the registrar's agent registration endpoints, including the -+security fix that prevents UUID spoofing via re-registration with -+a different TPM identity. -+""" -+ -+# type: ignore - Controller methods are dynamically bound -+ -+import unittest -+from typing import cast -+from unittest.mock import MagicMock, patch -+ -+from sqlalchemy.exc import IntegrityError -+ -+from keylime.web.registrar.agents_controller import AgentsController -+ -+ -+class TestAgentsControllerIndex(unittest.TestCase): -+ """Test cases for AgentsController.index().""" -+ -+ def setUp(self): -+ """Set up test fixtures.""" -+ mock_action_handler = MagicMock() -+ self.controller = cast(AgentsController, AgentsController(mock_action_handler)) -+ self.controller.respond = MagicMock() -+ -+ @patch("keylime.models.RegistrarAgent.all_ids") -+ def test_index_success(self, mock_all_ids): -+ """Test successful retrieval of all agent IDs.""" -+ mock_all_ids.return_value = ["agent-1", "agent-2", "agent-3"] -+ -+ self.controller.index() -+ -+ self.controller.respond.assert_called_once_with(200, "Success", {"uuids": ["agent-1", "agent-2", "agent-3"]}) # type: ignore[attr-defined] -+ -+ -+class TestAgentsControllerShow(unittest.TestCase): -+ """Test cases for AgentsController.show().""" -+ -+ def setUp(self): -+ """Set up test fixtures.""" -+ mock_action_handler = MagicMock() -+ self.controller = cast(AgentsController, AgentsController(mock_action_handler)) -+ self.controller.respond = MagicMock() -+ self.test_agent_id = "test-agent-123" -+ -+ @patch("keylime.models.RegistrarAgent.get") -+ def test_show_not_found(self, mock_get): -+ """Test show with non-existent agent.""" -+ mock_get.return_value = None -+ -+ self.controller.show(self.test_agent_id) -+ -+ self.controller.respond.assert_called_once_with(404, f"Agent with ID '{self.test_agent_id}' not found") # type: ignore[attr-defined] -+ -+ @patch("keylime.models.RegistrarAgent.get") -+ def test_show_not_active(self, mock_get): -+ """Test show with inactive agent.""" -+ mock_agent = MagicMock() -+ mock_agent.active = False -+ mock_get.return_value = mock_agent -+ -+ self.controller.show(self.test_agent_id) -+ -+ self.controller.respond.assert_called_once_with( # type: ignore[attr-defined] -+ 404, f"Agent with ID '{self.test_agent_id}' has not been activated" -+ ) -+ -+ @patch("keylime.models.RegistrarAgent.get") -+ def test_show_success(self, mock_get): -+ """Test successful show of active agent.""" -+ mock_agent = MagicMock() -+ mock_agent.active = True -+ mock_agent.render.return_value = {"agent_id": self.test_agent_id, "active": True} -+ mock_get.return_value = mock_agent -+ -+ self.controller.show(self.test_agent_id) -+ -+ self.controller.respond.assert_called_once_with( # type: ignore[attr-defined] -+ 200, "Success", {"agent_id": self.test_agent_id, "active": True} -+ ) -+ -+ -+class TestAgentsControllerCreate(unittest.TestCase): -+ """Test cases for AgentsController.create() - the main registration endpoint.""" -+ -+ def setUp(self): -+ """Set up test fixtures.""" -+ mock_action_handler = MagicMock() -+ self.controller = cast(AgentsController, AgentsController(mock_action_handler)) -+ self.controller.respond = MagicMock() -+ self.controller.log_model_errors = MagicMock() -+ self.test_agent_id = "test-agent-123" -+ -+ @patch("keylime.models.RegistrarAgent.get") -+ def test_create_new_agent_success(self, mock_get): -+ """Test successful registration of a new agent.""" -+ # Mock that agent doesn't exist yet -+ mock_get.return_value = None -+ -+ # Create mock agent that will be returned by empty() -+ mock_agent = MagicMock() -+ mock_agent.changes_valid = True -+ mock_agent.errors = {} -+ mock_agent.produce_ak_challenge.return_value = "challenge_blob_data" -+ -+ # Patch RegistrarAgent.empty to return our mock -+ with patch("keylime.models.RegistrarAgent.empty", return_value=mock_agent): -+ params = {"ek_tpm": "ek_key", "aik_tpm": "aik_key"} -+ self.controller.create(self.test_agent_id, **params) -+ -+ # Verify agent was updated with params -+ mock_agent.update.assert_called_once_with({"agent_id": self.test_agent_id, **params}) -+ -+ # Verify challenge was generated -+ mock_agent.produce_ak_challenge.assert_called_once() -+ -+ # Verify agent was saved -+ mock_agent.commit_changes.assert_called_once() -+ -+ # Verify 200 response with challenge -+ self.controller.respond.assert_called_once_with(200, "Success", {"blob": "challenge_blob_data"}) # type: ignore[attr-defined] -+ -+ @patch("keylime.models.RegistrarAgent.get") -+ def test_create_reregistration_same_tpm_identity(self, mock_get): -+ """Test successful re-registration with same TPM identity.""" -+ # Mock existing agent -+ mock_existing_agent = MagicMock() -+ mock_existing_agent.changes_valid = True -+ mock_existing_agent.errors = {} -+ mock_existing_agent.produce_ak_challenge.return_value = "challenge_blob_data" -+ mock_get.return_value = mock_existing_agent -+ -+ params = {"ek_tpm": "same_ek_key", "aik_tpm": "same_aik_key"} -+ self.controller.create(self.test_agent_id, **params) -+ -+ # Verify agent was updated -+ mock_existing_agent.update.assert_called_once_with({"agent_id": self.test_agent_id, **params}) -+ -+ # Verify challenge was generated -+ mock_existing_agent.produce_ak_challenge.assert_called_once() -+ -+ # Verify agent was saved -+ mock_existing_agent.commit_changes.assert_called_once() -+ -+ # Verify 200 response -+ self.controller.respond.assert_called_once_with(200, "Success", {"blob": "challenge_blob_data"}) # type: ignore[attr-defined] -+ -+ @patch("keylime.models.RegistrarAgent.get") -+ def test_create_reregistration_different_tpm_identity_forbidden(self, mock_get): -+ """Test re-registration with different TPM identity is rejected with 403. -+ -+ This is the key security fix: preventing UUID spoofing by rejecting -+ attempts to re-register an agent with a different TPM identity. -+ """ -+ # Mock existing agent -+ mock_existing_agent = MagicMock() -+ mock_existing_agent.changes_valid = False # Validation failed -+ # Simulate the error added by _check_tpm_identity_immutable -+ mock_existing_agent.errors = { -+ "agent_id": [ -+ "Agent re-registration attempted with different TPM identity (changed fields: ek_tpm). " -+ "This is a security violation - the same agent UUID cannot be reused with a different TPM." -+ ] -+ } -+ mock_get.return_value = mock_existing_agent -+ -+ params = {"ek_tpm": "different_ek_key", "aik_tpm": "same_aik_key"} -+ self.controller.create(self.test_agent_id, **params) -+ -+ # Verify agent was updated (which triggers validation) -+ mock_existing_agent.update.assert_called_once_with({"agent_id": self.test_agent_id, **params}) -+ -+ # Verify errors were logged -+ self.controller.log_model_errors.assert_called_once() # type: ignore[attr-defined] -+ -+ # Verify 403 Forbidden response (not 400!) -+ self.controller.respond.assert_called_once_with( # type: ignore[attr-defined] -+ 403, "Agent re-registration with different TPM identity is forbidden for security reasons" -+ ) -+ -+ # Verify agent was NOT saved -+ mock_existing_agent.commit_changes.assert_not_called() -+ -+ @patch("keylime.models.RegistrarAgent.get") -+ def test_create_invalid_data_other_validation_error(self, mock_get): -+ """Test registration with other validation errors returns 400.""" -+ # Mock agent with validation errors (not TPM identity related) -+ mock_agent = MagicMock() -+ mock_agent.changes_valid = False -+ # Error not related to TPM identity -+ mock_agent.errors = {"ek_tpm": ["must be a valid TPM2B_PUBLIC structure"]} -+ mock_agent.has_tpm_identity_violation = False # Not a TPM identity violation -+ mock_agent.produce_ak_challenge.return_value = None -+ mock_get.return_value = None -+ -+ with patch("keylime.models.RegistrarAgent.empty", return_value=mock_agent): -+ params = {"ek_tpm": "invalid_ek_format"} -+ self.controller.create(self.test_agent_id, **params) -+ -+ # Verify errors were logged -+ self.controller.log_model_errors.assert_called_once() # type: ignore[attr-defined] -+ -+ # Verify 400 Bad Request (not 403) -+ self.controller.respond.assert_called_once_with(400, "Could not register agent with invalid data") # type: ignore[attr-defined] -+ -+ # Verify agent was NOT saved -+ mock_agent.commit_changes.assert_not_called() -+ -+ @patch("keylime.models.RegistrarAgent.get") -+ def test_create_challenge_generation_failure(self, mock_get): -+ """Test registration fails if challenge generation fails.""" -+ # Mock agent where challenge generation fails -+ mock_agent = MagicMock() -+ mock_agent.changes_valid = True -+ mock_agent.errors = {} -+ mock_agent.produce_ak_challenge.return_value = None # Challenge generation failed -+ mock_get.return_value = None -+ -+ with patch("keylime.models.RegistrarAgent.empty", return_value=mock_agent): -+ params = {"ek_tpm": "ek_key", "aik_tpm": "aik_key"} -+ self.controller.create(self.test_agent_id, **params) -+ -+ # Verify errors were logged -+ self.controller.log_model_errors.assert_called_once() # type: ignore[attr-defined] -+ -+ # Verify 400 response -+ self.controller.respond.assert_called_once_with(400, "Could not register agent with invalid data") # type: ignore[attr-defined] -+ -+ # Verify agent was NOT saved -+ mock_agent.commit_changes.assert_not_called() -+ -+ @patch("keylime.models.RegistrarAgent.get") -+ def test_create_validation_error_with_agent_id_but_not_tpm_identity(self, mock_get): -+ """Test that agent_id errors unrelated to TPM identity get 400, not 403.""" -+ # Mock agent with agent_id error, but not about TPM identity -+ mock_agent = MagicMock() -+ mock_agent.changes_valid = False -+ mock_agent.errors = {"agent_id": ["must be a valid UUID format"]} # Not about TPM identity -+ mock_agent.has_tpm_identity_violation = False # Not a TPM identity violation -+ mock_agent.produce_ak_challenge.return_value = None -+ mock_get.return_value = None -+ -+ with patch("keylime.models.RegistrarAgent.empty", return_value=mock_agent): -+ params = {"ek_tpm": "ek_key", "aik_tpm": "aik_key"} -+ self.controller.create(self.test_agent_id, **params) -+ -+ # Verify 400 Bad Request (not 403) because it's not a TPM identity violation -+ self.controller.respond.assert_called_once_with(400, "Could not register agent with invalid data") # type: ignore[attr-defined] -+ -+ -+class TestAgentsControllerDelete(unittest.TestCase): -+ """Test cases for AgentsController.delete().""" -+ -+ def setUp(self): -+ """Set up test fixtures.""" -+ mock_action_handler = MagicMock() -+ self.controller = cast(AgentsController, AgentsController(mock_action_handler)) -+ self.controller.respond = MagicMock() -+ self.test_agent_id = "test-agent-123" -+ -+ @patch("keylime.models.RegistrarAgent.get") -+ def test_delete_not_found(self, mock_get): -+ """Test delete with non-existent agent.""" -+ mock_get.return_value = None -+ -+ self.controller.delete(self.test_agent_id) -+ -+ self.controller.respond.assert_called_once_with(404, f"Agent with ID '{self.test_agent_id}' not found") # type: ignore[attr-defined] -+ -+ @patch("keylime.models.RegistrarAgent.get") -+ def test_delete_success(self, mock_get): -+ """Test successful agent deletion.""" -+ mock_agent = MagicMock() -+ mock_get.return_value = mock_agent -+ -+ self.controller.delete(self.test_agent_id) -+ -+ # Verify agent was deleted -+ mock_agent.delete.assert_called_once() -+ -+ # Verify 200 response -+ self.controller.respond.assert_called_once_with(200, "Success") # type: ignore[attr-defined] -+ -+ -+class TestAgentsControllerActivate(unittest.TestCase): -+ """Test cases for AgentsController.activate().""" -+ -+ def setUp(self): -+ """Set up test fixtures.""" -+ mock_action_handler = MagicMock() -+ self.controller = cast(AgentsController, AgentsController(mock_action_handler)) -+ self.controller.respond = MagicMock() -+ self.test_agent_id = "test-agent-123" -+ self.test_auth_tag = "valid_auth_tag" -+ -+ @patch("keylime.models.RegistrarAgent.get") -+ def test_activate_not_found(self, mock_get): -+ """Test activate with non-existent agent.""" -+ mock_get.return_value = None -+ -+ self.controller.activate(self.test_agent_id, self.test_auth_tag) -+ -+ self.controller.respond.assert_called_once_with(404, f"Agent with ID '{self.test_agent_id}' not found") # type: ignore[attr-defined] -+ -+ @patch("keylime.models.RegistrarAgent.get") -+ def test_activate_success(self, mock_get): -+ """Test successful agent activation.""" -+ mock_agent = MagicMock() -+ mock_agent.verify_ak_response.return_value = True # Auth tag is valid -+ mock_get.return_value = mock_agent -+ -+ self.controller.activate(self.test_agent_id, self.test_auth_tag) -+ -+ # Verify auth tag was verified -+ mock_agent.verify_ak_response.assert_called_once_with(self.test_auth_tag) -+ -+ # Verify agent was saved -+ mock_agent.commit_changes.assert_called_once() -+ -+ # Verify 200 response -+ self.controller.respond.assert_called_once_with(200, "Success") # type: ignore[attr-defined] -+ -+ @patch("keylime.models.RegistrarAgent.get") -+ def test_activate_invalid_auth_tag(self, mock_get): -+ """Test activation with invalid auth tag.""" -+ mock_agent = MagicMock() -+ mock_agent.verify_ak_response.return_value = False # Auth tag is invalid -+ mock_get.return_value = mock_agent -+ -+ self.controller.activate(self.test_agent_id, self.test_auth_tag) -+ -+ # Verify auth tag was verified -+ mock_agent.verify_ak_response.assert_called_once_with(self.test_auth_tag) -+ -+ # Verify agent was deleted (due to failed activation) -+ mock_agent.delete.assert_called_once() -+ -+ # Verify agent was NOT saved -+ mock_agent.commit_changes.assert_not_called() -+ -+ # Verify 400 response with detailed error message -+ self.controller.respond.assert_called_once() # type: ignore[attr-defined] -+ call_args = self.controller.respond.call_args # type: ignore[attr-defined] -+ self.assertEqual(call_args[0][0], 400) -+ self.assertIn(self.test_auth_tag, call_args[0][1]) -+ self.assertIn(self.test_agent_id, call_args[0][1]) -+ self.assertIn("deleted", call_args[0][1]) -+ -+ -+class TestAgentsControllerConcurrency(unittest.TestCase): -+ """Test cases for concurrent registration TOCTOU race condition protection.""" -+ -+ def setUp(self): -+ """Set up test fixtures.""" -+ mock_action_handler = MagicMock() -+ self.controller = cast(AgentsController, AgentsController(mock_action_handler)) -+ self.controller.respond = MagicMock() -+ self.controller.log_model_errors = MagicMock() -+ self.test_agent_id = "concurrent-test-agent" -+ -+ @patch("keylime.web.registrar.agents_controller.get_shared_memory") -+ @patch("keylime.models.RegistrarAgent.get") -+ def test_concurrent_registration_uses_locking(self, mock_get, mock_shared_mem): -+ """Test that concurrent registration attempts use per-agent locking. -+ -+ This test verifies that the locking mechanism is invoked to prevent -+ TOCTOU race conditions during concurrent registration. -+ """ -+ # Mock shared memory manager with lock support -+ mock_manager = MagicMock() -+ mock_lock = MagicMock() -+ mock_lock.__enter__ = MagicMock(return_value=None) -+ mock_lock.__exit__ = MagicMock(return_value=None) -+ mock_manager.Lock.return_value = mock_lock -+ -+ mock_agent_locks = MagicMock() -+ mock_agent_locks.__contains__ = MagicMock(return_value=False) -+ mock_agent_locks.__setitem__ = MagicMock() -+ mock_agent_locks.__getitem__ = MagicMock(return_value=mock_lock) -+ -+ mock_shared_mem.return_value.get_or_create_dict.return_value = mock_agent_locks -+ mock_shared_mem.return_value.manager = mock_manager -+ -+ # Mock agent that doesn't exist yet (new registration) -+ mock_get.return_value = None -+ -+ mock_agent = MagicMock() -+ mock_agent.changes_valid = True -+ mock_agent.errors = {} -+ mock_agent.produce_ak_challenge.return_value = "challenge_blob" -+ -+ with patch("keylime.models.RegistrarAgent.empty", return_value=mock_agent): -+ params = {"ek_tpm": "ek_key", "aik_tpm": "aik_key"} -+ self.controller.create(self.test_agent_id, **params) -+ -+ # Verify lock was acquired and released -+ mock_lock.__enter__.assert_called_once() -+ mock_lock.__exit__.assert_called_once() -+ -+ # Verify successful registration -+ mock_agent.commit_changes.assert_called_once() -+ self.controller.respond.assert_called_once_with(200, "Success", {"blob": "challenge_blob"}) # type: ignore[attr-defined] -+ -+ @patch("keylime.web.registrar.agents_controller.get_shared_memory") -+ @patch("keylime.models.RegistrarAgent.get") -+ def test_different_agents_use_different_locks(self, mock_get, mock_shared_mem): -+ """Test that different agent_ids use different locks for parallel registration. -+ -+ This ensures that registrations for different agents don't block each other, -+ only concurrent registrations for the same agent_id are serialized. -+ """ -+ # Mock shared memory manager -+ mock_manager = MagicMock() -+ -+ def mock_lock_factory(): -+ """Return a new lock each time.""" -+ return MagicMock() -+ -+ mock_manager.Lock.side_effect = mock_lock_factory -+ -+ mock_agent_locks = {} -+ -+ def mock_getitem(_self, key): # pylint: disable=unused-argument -+ return mock_agent_locks.get(key) -+ -+ def mock_setitem(_self, key, value): # pylint: disable=unused-argument -+ mock_agent_locks[key] = value -+ -+ def mock_contains(_self, key): # pylint: disable=unused-argument -+ return key in mock_agent_locks -+ -+ mock_locks_dict = MagicMock() -+ mock_locks_dict.__contains__ = mock_contains -+ mock_locks_dict.__setitem__ = mock_setitem -+ mock_locks_dict.__getitem__ = mock_getitem -+ -+ mock_shared_mem.return_value.get_or_create_dict.return_value = mock_locks_dict -+ mock_shared_mem.return_value.manager = mock_manager -+ -+ # Register two different agents -+ mock_get.return_value = None -+ -+ for agent_id in ["agent-a", "agent-b"]: -+ mock_agent = MagicMock() -+ mock_agent.changes_valid = True -+ mock_agent.errors = {} -+ mock_agent.produce_ak_challenge.return_value = f"challenge_{agent_id}" -+ -+ with patch("keylime.models.RegistrarAgent.empty", return_value=mock_agent): -+ self.controller.respond = MagicMock() # Reset for each call -+ params = {"ek_tpm": f"ek_{agent_id}", "aik_tpm": f"aik_{agent_id}"} -+ self.controller.create(agent_id, **params) -+ -+ # Verify that two different locks were created (one per agent) -+ self.assertEqual(len(mock_agent_locks), 2) -+ self.assertIn("agent-a", mock_agent_locks) -+ self.assertIn("agent-b", mock_agent_locks) -+ # Verify they are different lock objects -+ self.assertIsNot(mock_agent_locks["agent-a"], mock_agent_locks["agent-b"]) -+ -+ @patch("keylime.web.registrar.agents_controller.get_shared_memory") -+ @patch("keylime.models.RegistrarAgent.get") -+ def test_concurrent_new_registration_database_constraint_violation(self, mock_get, mock_shared_mem): -+ """Test that database constraint violations during concurrent new agent registration return 403. -+ -+ This handles the edge case where two requests both create empty agents for the same UUID, -+ both pass validation, but the second commit fails with IntegrityError due to duplicate -+ primary key. This should return 403 Forbidden, not 500 Internal Server Error. -+ """ -+ # Mock shared memory manager with lock support -+ mock_manager = MagicMock() -+ mock_lock = MagicMock() -+ mock_lock.__enter__ = MagicMock(return_value=None) -+ mock_lock.__exit__ = MagicMock(return_value=None) -+ mock_manager.Lock.return_value = mock_lock -+ -+ mock_agent_locks = MagicMock() -+ mock_agent_locks.__contains__ = MagicMock(return_value=False) -+ mock_agent_locks.__setitem__ = MagicMock() -+ mock_agent_locks.__getitem__ = MagicMock(return_value=mock_lock) -+ -+ mock_shared_mem.return_value.get_or_create_dict.return_value = mock_agent_locks -+ mock_shared_mem.return_value.manager = mock_manager -+ -+ # Mock agent that doesn't exist yet (new registration) -+ mock_get.return_value = None -+ -+ mock_agent = MagicMock() -+ mock_agent.changes_valid = True -+ mock_agent.errors = {} -+ mock_agent.produce_ak_challenge.return_value = "challenge_blob" -+ -+ # Simulate IntegrityError during commit (duplicate primary key) -+ # IntegrityError(statement, params, orig) where orig is the original exception -+ orig_exception = Exception("UNIQUE constraint failed: registrarmain.agent_id") -+ mock_agent.commit_changes.side_effect = IntegrityError("INSERT INTO registrarmain ...", None, orig_exception) -+ -+ with patch("keylime.models.RegistrarAgent.empty", return_value=mock_agent): -+ params = {"ek_tpm": "ek_key", "aik_tpm": "aik_key"} -+ self.controller.create(self.test_agent_id, **params) -+ -+ # Verify 403 Forbidden response (not 500) -+ self.controller.respond.assert_called_once() # type: ignore[attr-defined] -+ call_args = self.controller.respond.call_args # type: ignore[attr-defined] -+ self.assertEqual(call_args[0][0], 403) -+ self.assertIn(self.test_agent_id, call_args[0][1]) -+ self.assertIn("already in use", call_args[0][1]) -+ -+ -+if __name__ == "__main__": -+ unittest.main() -diff --git a/test/test_registrar_tpm_identity.py b/test/test_registrar_tpm_identity.py -new file mode 100644 -index 0000000..2fc69b2 ---- /dev/null -+++ b/test/test_registrar_tpm_identity.py -@@ -0,0 +1,342 @@ -+""" -+Unit tests for RegistrarAgent TPM identity immutability security check. -+ -+This module tests the _check_tpm_identity_immutable() method which prevents -+UUID spoofing attacks by rejecting re-registration attempts with different TPM identities. -+""" -+ -+import base64 -+import types -+import unittest -+from unittest.mock import Mock -+ -+import cryptography.x509 -+ -+from keylime.certificate_wrapper import wrap_certificate -+from keylime.models.registrar.registrar_agent import RegistrarAgent -+ -+ -+class TestRegistrarAgentTPMIdentity(unittest.TestCase): -+ """Test cases for RegistrarAgent TPM identity immutability.""" -+ -+ # pylint: disable=protected-access # Testing protected methods -+ # pylint: disable=not-callable # False positive: methods bound via types.MethodType are callable -+ -+ def setUp(self): -+ """Set up test fixtures.""" -+ # EK certificate (used for testing certificate comparison) -+ self.ek_cert_pem = """-----BEGIN CERTIFICATE----- -+MIIEnzCCA4egAwIBAgIEMV64bDANBgkqhkiG9w0BAQUFADBtMQswCQYDVQQGEwJE -+RTEQMA4GA1UECBMHQmF2YXJpYTEhMB8GA1UEChMYSW5maW5lb24gVGVjaG5vbG9n -+aWVzIEFHMQwwCgYDVQQLEwNBSU0xGzAZBgNVBAMTEklGWCBUUE0gRUsgUm9vdCBD -+QTAeFw0wNTEwMjAxMzQ3NDNaFw0yNTEwMjAxMzQ3NDNaMHcxCzAJBgNVBAYTAkRF -+MQ8wDQYDVQQIEwZTYXhvbnkxITAfBgNVBAoTGEluZmluZW9uIFRlY2hub2xvZ2ll -+cyBBRzEMMAoGA1UECxMDQUlNMSYwJAYDVQQDEx1JRlggVFBNIEVLIEludGVybWVk -+aWF0ZSBDQSAwMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALftPhYN -+t4rE+JnU/XOPICbOBLvfo6iA7nuq7zf4DzsAWBdsZEdFJQfaK331ihG3IpQnlQ2i -+YtDim289265f0J4OkPFpKeFU27CsfozVaNUm6UR/uzwA8ncxFc3iZLRMRNLru/Al -+VG053ULVDQMVx2iwwbBSAYO9pGiGbk1iMmuZaSErMdb9v0KRUyZM7yABiyDlM3cz -+UQX5vLWV0uWqxdGoHwNva5u3ynP9UxPTZWHZOHE6+14rMzpobs6Ww2RR8BgF96rh -+4rRAZEl8BXhwiQq4STvUXkfvdpWH4lzsGcDDtrB6Nt3KvVNvsKz+b07Dk+Xzt+EH -+NTf3Byk2HlvX+scCAwEAAaOCATswggE3MB0GA1UdDgQWBBQ4k8292HPEIzMV4bE7 -+qWoNI8wQxzAOBgNVHQ8BAf8EBAMCAgQwEgYDVR0TAQH/BAgwBgEB/wIBADBYBgNV -+HSABAf8ETjBMMEoGC2CGSAGG+EUBBy8BMDswOQYIKwYBBQUHAgEWLWh0dHA6Ly93 -+d3cudmVyaXNpZ24uY29tL3JlcG9zaXRvcnkvaW5kZXguaHRtbDCBlwYDVR0jBIGP -+MIGMgBRW65FEhWPWcrOu1EWWC/eUDlRCpqFxpG8wbTELMAkGA1UEBhMCREUxEDAO -+BgNVBAgTB0JhdmFyaWExITAfBgNVBAoTGEluZmluZW9uIFRlY2hub2xvZ2llcyBB -+RzEMMAoGA1UECxMDQUlNMRswGQYDVQQDExJJRlggVFBNIEVLIFJvb3QgQ0GCAQMw -+DQYJKoZIhvcNAQEFBQADggEBABJ1+Ap3rNlxZ0FW0aIgdzktbNHlvXWNxFdYIBbM -+OKjmbOos0Y4O60eKPu259XmMItCUmtbzF3oKYXq6ybARUT2Lm+JsseMF5VgikSlU -+BJALqpKVjwAds81OtmnIQe2LSu4xcTSavpsL4f52cUAu/maMhtSgN9mq5roYptq9 -+DnSSDZrX4uYiMPl//rBaNDBflhJ727j8xo9CCohF3yQUoQm7coUgbRMzyO64yMIO -+3fhb+Vuc7sNwrMOz3VJN14C3JMoGgXy0c57IP/kD5zGRvljKEvrRC2I147+fPeLS -+DueRMS6lblvRKiZgmGAg7YaKOkOaEmVDMQ+fTo2Po7hI5wc= -+-----END CERTIFICATE-----""" -+ -+ # Create wrapped cert from real certificate -+ self.ek_cert = cryptography.x509.load_pem_x509_certificate(self.ek_cert_pem.encode()) -+ self.ek_cert_wrapped = wrap_certificate(self.ek_cert, None) -+ -+ # Create a different cert mock that returns different DER bytes -+ self.different_ek_cert_wrapped = Mock() -+ self.different_ek_cert_wrapped.public_bytes = Mock(return_value=b"DIFFERENT_CERTIFICATE_DER_BYTES_FOR_TESTING") -+ -+ # Sample TPM keys (base64 encoded for simplicity in tests) -+ self.ek_tpm_1 = b"EK_TPM_KEY_NUMBER_ONE_SAMPLE_DATA" -+ self.ek_tpm_2 = b"EK_TPM_KEY_NUMBER_TWO_DIFFERENT_" -+ self.aik_tpm_1 = b"AIK_TPM_KEY_NUMBER_ONE_SAMPLE_DATA" -+ self.aik_tpm_2 = b"AIK_TPM_KEY_NUMBER_TWO_DIFFERENT_" -+ -+ # IAK/IDevID keys for testing that they are not checked -+ self.iak_tpm_1 = b"IAK_TPM_KEY_NUMBER_ONE" -+ self.iak_tpm_2 = b"IAK_TPM_KEY_NUMBER_TWO" -+ -+ def create_mock_registrar_agent(self, agent_id="test-agent-uuid"): -+ """Create a mock RegistrarAgent with necessary attributes.""" -+ agent = Mock() -+ agent.agent_id = agent_id -+ agent.changes = {} -+ agent.values = {} -+ agent.committed = {} -+ agent._add_error = Mock() -+ agent.errors = {} -+ -+ # Bind the actual method to the mock instance -+ agent._check_tpm_identity_immutable = types.MethodType(RegistrarAgent._check_tpm_identity_immutable, agent) -+ -+ return agent -+ -+ def test_new_agent_no_committed_values(self): -+ """Test that new agents (no committed values) are not checked.""" -+ agent = self.create_mock_registrar_agent() -+ agent.committed = {} # New agent, no previous values -+ agent.changes = { -+ "ek_tpm": self.ek_tpm_1, -+ "ekcert": self.ek_cert_wrapped, -+ "aik_tpm": self.aik_tpm_1, -+ } -+ -+ agent._check_tpm_identity_immutable() -+ -+ # Should not add any errors for new agents -+ agent._add_error.assert_not_called() -+ -+ def test_reregistration_same_tpm_all_fields_identical(self): -+ """Test re-registration with identical TPM identity passes.""" -+ agent = self.create_mock_registrar_agent() -+ agent.committed = { -+ "ek_tpm": self.ek_tpm_1, -+ "ekcert": self.ek_cert_wrapped, -+ "aik_tpm": self.aik_tpm_1, -+ } -+ agent.changes = { -+ "ek_tpm": self.ek_tpm_1, # Same -+ "ekcert": self.ek_cert_wrapped, # Same -+ "aik_tpm": self.aik_tpm_1, # Same -+ } -+ -+ agent._check_tpm_identity_immutable() -+ -+ # Should not add any errors -+ agent._add_error.assert_not_called() -+ -+ def test_reregistration_different_ek_tpm(self): -+ """Test re-registration with different EK TPM is rejected.""" -+ agent = self.create_mock_registrar_agent() -+ agent.committed = { -+ "ek_tpm": self.ek_tpm_1, -+ "ekcert": self.ek_cert_wrapped, -+ "aik_tpm": self.aik_tpm_1, -+ } -+ agent.changes = { -+ "ek_tpm": self.ek_tpm_2, # DIFFERENT -+ "ekcert": self.ek_cert_wrapped, -+ "aik_tpm": self.aik_tpm_1, -+ } -+ -+ agent._check_tpm_identity_immutable() -+ -+ # Should add error for agent_id field -+ agent._add_error.assert_called_once() -+ call_args = agent._add_error.call_args -+ self.assertEqual(call_args[0][0], "agent_id") -+ self.assertIn("different TPM identity", call_args[0][1]) -+ self.assertIn("ek_tpm", call_args[0][1]) -+ -+ def test_reregistration_different_aik_tpm(self): -+ """Test re-registration with different AIK TPM is rejected.""" -+ agent = self.create_mock_registrar_agent() -+ agent.committed = { -+ "ek_tpm": self.ek_tpm_1, -+ "ekcert": self.ek_cert_wrapped, -+ "aik_tpm": self.aik_tpm_1, -+ } -+ agent.changes = { -+ "ek_tpm": self.ek_tpm_1, -+ "ekcert": self.ek_cert_wrapped, -+ "aik_tpm": self.aik_tpm_2, # DIFFERENT -+ } -+ -+ agent._check_tpm_identity_immutable() -+ -+ # Should add error for agent_id field -+ agent._add_error.assert_called_once() -+ call_args = agent._add_error.call_args -+ self.assertEqual(call_args[0][0], "agent_id") -+ self.assertIn("different TPM identity", call_args[0][1]) -+ self.assertIn("aik_tpm", call_args[0][1]) -+ -+ def test_reregistration_different_ekcert(self): -+ """Test re-registration with different EK certificate is rejected.""" -+ agent = self.create_mock_registrar_agent() -+ agent.committed = { -+ "ek_tpm": self.ek_tpm_1, -+ "ekcert": self.ek_cert_wrapped, -+ "aik_tpm": self.aik_tpm_1, -+ } -+ agent.changes = { -+ "ek_tpm": self.ek_tpm_1, -+ "ekcert": self.different_ek_cert_wrapped, # DIFFERENT -+ "aik_tpm": self.aik_tpm_1, -+ } -+ -+ agent._check_tpm_identity_immutable() -+ -+ # Should add error for agent_id field -+ agent._add_error.assert_called_once() -+ call_args = agent._add_error.call_args -+ self.assertEqual(call_args[0][0], "agent_id") -+ self.assertIn("different TPM identity", call_args[0][1]) -+ self.assertIn("ekcert", call_args[0][1]) -+ -+ def test_reregistration_multiple_fields_changed(self): -+ """Test re-registration with multiple fields changed lists all of them.""" -+ agent = self.create_mock_registrar_agent() -+ agent.committed = { -+ "ek_tpm": self.ek_tpm_1, -+ "ekcert": self.ek_cert_wrapped, -+ "aik_tpm": self.aik_tpm_1, -+ } -+ agent.changes = { -+ "ek_tpm": self.ek_tpm_2, # DIFFERENT -+ "ekcert": self.different_ek_cert_wrapped, # DIFFERENT -+ "aik_tpm": self.aik_tpm_2, # DIFFERENT -+ } -+ -+ agent._check_tpm_identity_immutable() -+ -+ # Should add error listing all changed fields -+ agent._add_error.assert_called_once() -+ call_args = agent._add_error.call_args -+ self.assertEqual(call_args[0][0], "agent_id") -+ error_message = call_args[0][1] -+ self.assertIn("ek_tpm", error_message) -+ self.assertIn("ekcert", error_message) -+ self.assertIn("aik_tpm", error_message) -+ -+ def test_adding_ekcert_to_existing_agent(self): -+ """Test that adding EK cert to existing agent (without cert) is allowed.""" -+ agent = self.create_mock_registrar_agent() -+ agent.committed = { -+ "ek_tpm": self.ek_tpm_1, -+ "ekcert": None, # Previously no cert -+ "aik_tpm": self.aik_tpm_1, -+ } -+ agent.changes = { -+ "ek_tpm": self.ek_tpm_1, -+ "ekcert": self.ek_cert_wrapped, # NOW adding cert -+ "aik_tpm": self.aik_tpm_1, -+ } -+ -+ agent._check_tpm_identity_immutable() -+ -+ # Should not add any errors - adding cert is allowed -+ agent._add_error.assert_not_called() -+ -+ def test_removing_ek_tpm_rejected(self): -+ """Test that removing an existing EK TPM is rejected.""" -+ agent = self.create_mock_registrar_agent() -+ agent.committed = { -+ "ek_tpm": self.ek_tpm_1, -+ "ekcert": self.ek_cert_wrapped, -+ "aik_tpm": self.aik_tpm_1, -+ } -+ agent.changes = { -+ "ek_tpm": None, # Trying to remove -+ "ekcert": self.ek_cert_wrapped, -+ "aik_tpm": self.aik_tpm_1, -+ } -+ -+ agent._check_tpm_identity_immutable() -+ -+ # Should add error -+ agent._add_error.assert_called_once() -+ call_args = agent._add_error.call_args -+ self.assertIn("ek_tpm", call_args[0][1]) -+ -+ def test_iak_idevid_changes_not_checked(self): -+ """Test that IAK/IDevID field changes are NOT checked (allowed).""" -+ agent = self.create_mock_registrar_agent() -+ agent.committed = { -+ "ek_tpm": self.ek_tpm_1, -+ "ekcert": self.ek_cert_wrapped, -+ "aik_tpm": self.aik_tpm_1, -+ "iak_tpm": self.iak_tpm_1, -+ "idevid_tpm": b"IDEVID_OLD", -+ } -+ agent.changes = { -+ "ek_tpm": self.ek_tpm_1, # Same -+ "ekcert": self.ek_cert_wrapped, # Same -+ "aik_tpm": self.aik_tpm_1, # Same -+ "iak_tpm": self.iak_tpm_2, # DIFFERENT - but not checked -+ "idevid_tpm": b"IDEVID_NEW", # DIFFERENT - but not checked -+ } -+ -+ agent._check_tpm_identity_immutable() -+ -+ # Should not add any errors - IAK/IDevID are not checked -+ agent._add_error.assert_not_called() -+ -+ def test_only_changed_fields_are_checked(self): -+ """Test that only fields in changes dict are checked.""" -+ agent = self.create_mock_registrar_agent() -+ agent.committed = { -+ "ek_tpm": self.ek_tpm_1, -+ "ekcert": self.ek_cert_wrapped, -+ "aik_tpm": self.aik_tpm_1, -+ } -+ # Only updating IP, not touching TPM identity fields -+ agent.changes = { -+ "ip": "192.168.1.100", -+ } -+ -+ agent._check_tpm_identity_immutable() -+ -+ # Should not add any errors - no identity fields changed -+ agent._add_error.assert_not_called() -+ -+ def test_base64_encoded_tpm_keys(self): -+ """Test that base64-encoded TPM keys are properly compared.""" -+ agent = self.create_mock_registrar_agent() -+ -+ # Simulate keys stored as base64 strings (as they might be from database) -+ ek_b64 = base64.b64encode(self.ek_tpm_1).decode("utf-8") -+ aik_b64 = base64.b64encode(self.aik_tpm_1).decode("utf-8") -+ -+ agent.committed = { -+ "ek_tpm": self.ek_tpm_1, # As bytes -+ "aik_tpm": self.aik_tpm_1, # As bytes -+ } -+ agent.changes = { -+ "ek_tpm": ek_b64, # As base64 string -+ "aik_tpm": aik_b64, # As base64 string -+ } -+ -+ agent._check_tpm_identity_immutable() -+ -+ # Should not add any errors - should handle both formats -+ agent._add_error.assert_not_called() -+ -+ def test_partial_update_only_one_field(self): -+ """Test updating only one TPM field while others remain unchanged.""" -+ agent = self.create_mock_registrar_agent() -+ agent.committed = { -+ "ek_tpm": self.ek_tpm_1, -+ "ekcert": self.ek_cert_wrapped, -+ "aik_tpm": self.aik_tpm_1, -+ } -+ # Only changing AIK in this update -+ agent.changes = { -+ "aik_tpm": self.aik_tpm_2, # DIFFERENT -+ } -+ -+ agent._check_tpm_identity_immutable() -+ -+ # Should add error for the changed field -+ agent._add_error.assert_called_once() -+ call_args = agent._add_error.call_args -+ self.assertIn("aik_tpm", call_args[0][1]) -+ -+ -+if __name__ == "__main__": -+ unittest.main() --- -2.47.3 - diff --git a/0016-algorithms-add-support-for-specific-ECC-curve-algori.patch b/0016-algorithms-add-support-for-specific-ECC-curve-algori.patch deleted file mode 100644 index 81a6398..0000000 --- a/0016-algorithms-add-support-for-specific-ECC-curve-algori.patch +++ /dev/null @@ -1,382 +0,0 @@ -From 7a723f0938edf9ccc597507a4230922e9235cf18 Mon Sep 17 00:00:00 2001 -From: Sergio Correia -Date: Wed, 24 Sep 2025 07:20:53 +0100 -Subject: [PATCH 13/18] algorithms: add support for specific ECC curve - algorithms - -Extended the Encrypt enum to support specific ECC curves including: -- ecc192 (P-192) -- ecc224 (P-224) -- ecc256 (P-256) -- ecc384 (P-384) -- ecc521 (P-521) - -This enables Keylime to accept and validate different ECC curves -for TPM attestation operations. - -Also, when agent reports specific algorithm like 'ecc256' but tenant -configuration uses generic 'ecc', the is_accepted function now uses -bidirectional normalization to properly match algorithms. - -Signed-off-by: Sergio Correia ---- - keylime/common/algorithms.py | 28 +++- - test/test_algorithms.py | 275 ++++++++++++++++++++++++++++++++++- - 2 files changed, 301 insertions(+), 2 deletions(-) - -diff --git a/keylime/common/algorithms.py b/keylime/common/algorithms.py -index db12c26..bb22fb6 100644 ---- a/keylime/common/algorithms.py -+++ b/keylime/common/algorithms.py -@@ -9,7 +9,18 @@ def is_accepted(algorithm: str, accepted: List[Any]) -> bool: - @param algorithm: algorithm to be checked - @param accepted: a list of acceptable algorithms - """ -- return algorithm in accepted -+ # Check direct match first. -+ if algorithm in accepted: -+ return True -+ -+ # Check if any accepted algorithm normalizes to the same value as our algorithm -+ # This handles backwards compatibility cases like "ecc" accepting "ecc256". -+ normalized_algorithm = Encrypt.normalize(algorithm) -+ for accepted_alg in accepted: -+ if Encrypt.normalize(str(accepted_alg)) == normalized_algorithm: -+ return True -+ -+ return False - - - class Hash(str, enum.Enum): -@@ -74,11 +85,26 @@ class Hash(str, enum.Enum): - class Encrypt(str, enum.Enum): - RSA = "rsa" - ECC = "ecc" -+ ECC192 = "ecc192" -+ ECC224 = "ecc224" -+ ECC256 = "ecc256" -+ ECC384 = "ecc384" -+ ECC521 = "ecc521" - - @staticmethod - def is_recognized(algorithm: str) -> bool: -+ # Handle aliases to match agent behavior -+ if algorithm == "ecc": -+ algorithm = "ecc256" # Default ECC alias maps to P-256, same as the agent. - return algorithm in list(Encrypt) - -+ @staticmethod -+ def normalize(algorithm: str) -> str: -+ """Normalize algorithm string to handle aliases, matching the agent behavior""" -+ if algorithm == "ecc": -+ return "ecc256" # Default ECC alias maps to P-256. -+ return algorithm -+ - - class Sign(str, enum.Enum): - RSASSA = "rsassa" -diff --git a/test/test_algorithms.py b/test/test_algorithms.py -index b5a29c7..8a31fa9 100644 ---- a/test/test_algorithms.py -+++ b/test/test_algorithms.py -@@ -2,7 +2,7 @@ import os - import tempfile - import unittest - --from keylime.common.algorithms import Encrypt, Hash, Sign -+from keylime.common.algorithms import Encrypt, Hash, Sign, is_accepted - - - class TestHash(unittest.TestCase): -@@ -117,11 +117,88 @@ class TestEncrypt(unittest.TestCase): - "enc": "ecc", - "valid": True, - }, -+ { -+ "enc": "ecc192", -+ "valid": True, -+ }, -+ { -+ "enc": "ecc224", -+ "valid": True, -+ }, -+ { -+ "enc": "ecc256", -+ "valid": True, -+ }, -+ { -+ "enc": "ecc384", -+ "valid": True, -+ }, -+ { -+ "enc": "ecc521", -+ "valid": True, -+ }, - ] - - for c in test_cases: - self.assertEqual(Encrypt.is_recognized(c["enc"]), c["valid"], msg=f"enc = {c['enc']}") - -+ def test_enum_membership(self): -+ """Test that all ECC curve algorithms are members of the Encrypt enum""" -+ self.assertTrue(Encrypt.RSA in Encrypt) -+ self.assertTrue(Encrypt.ECC in Encrypt) -+ self.assertTrue(Encrypt.ECC192 in Encrypt) -+ self.assertTrue(Encrypt.ECC224 in Encrypt) -+ self.assertTrue(Encrypt.ECC256 in Encrypt) -+ self.assertTrue(Encrypt.ECC384 in Encrypt) -+ self.assertTrue(Encrypt.ECC521 in Encrypt) -+ -+ def test_normalize(self): -+ """Test the normalize method for handling ECC aliases""" -+ test_cases = [ -+ { -+ "input": "ecc", -+ "expected": "ecc256", -+ }, -+ { -+ "input": "ecc192", -+ "expected": "ecc192", -+ }, -+ { -+ "input": "ecc224", -+ "expected": "ecc224", -+ }, -+ { -+ "input": "ecc256", -+ "expected": "ecc256", -+ }, -+ { -+ "input": "ecc384", -+ "expected": "ecc384", -+ }, -+ { -+ "input": "ecc521", -+ "expected": "ecc521", -+ }, -+ { -+ "input": "rsa", -+ "expected": "rsa", -+ }, -+ ] -+ -+ for c in test_cases: -+ self.assertEqual(Encrypt.normalize(c["input"]), c["expected"], msg=f"input = {c['input']}") -+ -+ def test_normalize_ecc_alias_behavior(self): -+ """Test that ECC alias normalization matches agent behavior""" -+ # Test that "ecc" is recognized through alias handling -+ self.assertTrue(Encrypt.is_recognized("ecc")) -+ -+ # Test that normalize converts "ecc" to "ecc256" (P-256) -+ self.assertEqual(Encrypt.normalize("ecc"), "ecc256") -+ -+ # Test that direct ecc256 works -+ self.assertTrue(Encrypt.is_recognized("ecc256")) -+ - - class TestSign(unittest.TestCase): - def test_is_recognized(self): -@@ -158,3 +235,199 @@ class TestSign(unittest.TestCase): - - for c in test_cases: - self.assertEqual(Sign.is_recognized(c["sign"]), c["valid"], msg=f"sign = {c['sign']}") -+ -+ -+class TestIsAccepted(unittest.TestCase): -+ def test_direct_algorithm_matching(self): -+ """Test that direct algorithm matches work correctly""" -+ test_cases = [ -+ { -+ "algorithm": "ecc256", -+ "accepted": ["ecc256"], -+ "expected": True, -+ }, -+ { -+ "algorithm": "rsa", -+ "accepted": ["rsa"], -+ "expected": True, -+ }, -+ { -+ "algorithm": "ecc384", -+ "accepted": ["ecc256", "ecc384"], -+ "expected": True, -+ }, -+ { -+ "algorithm": "ecc521", -+ "accepted": ["ecc256"], -+ "expected": False, -+ }, -+ { -+ "algorithm": "unknown", -+ "accepted": ["rsa", "ecc256"], -+ "expected": False, -+ }, -+ ] -+ -+ for c in test_cases: -+ result = is_accepted(c["algorithm"], c["accepted"]) -+ self.assertEqual(result, c["expected"], msg=f"algorithm='{c['algorithm']}', accepted={c['accepted']}") -+ -+ def test_backwards_compatibility_ecc_normalization(self): -+ """Test backwards compatibility: 'ecc' in accepted list should accept specific ECC algorithms""" -+ test_cases = [ -+ { -+ "algorithm": "ecc256", -+ "accepted": ["ecc"], -+ "expected": True, -+ "desc": "ecc256 should be accepted when 'ecc' is in accepted list", -+ }, -+ { -+ "algorithm": "ecc384", -+ "accepted": ["ecc"], -+ "expected": False, -+ "desc": "ecc384 should NOT be accepted when only 'ecc' is in accepted list (ecc maps to ecc256)", -+ }, -+ { -+ "algorithm": "ecc521", -+ "accepted": ["ecc"], -+ "expected": False, -+ "desc": "ecc521 should NOT be accepted when only 'ecc' is in accepted list", -+ }, -+ { -+ "algorithm": "ecc192", -+ "accepted": ["ecc"], -+ "expected": False, -+ "desc": "ecc192 should NOT be accepted when only 'ecc' is in accepted list", -+ }, -+ ] -+ -+ for c in test_cases: -+ result = is_accepted(c["algorithm"], c["accepted"]) -+ self.assertEqual( -+ result, c["expected"], msg=f"{c['desc']} - algorithm='{c['algorithm']}', accepted={c['accepted']}" -+ ) -+ -+ def test_forward_compatibility_ecc_normalization(self): -+ """Test forward compatibility: specific ECC in accepted list should accept 'ecc' algorithm""" -+ test_cases = [ -+ { -+ "algorithm": "ecc", -+ "accepted": ["ecc256"], -+ "expected": True, -+ "desc": "ecc should be accepted when 'ecc256' is in accepted list (both normalize to ecc256)", -+ }, -+ { -+ "algorithm": "ecc", -+ "accepted": ["ecc384"], -+ "expected": False, -+ "desc": "ecc should NOT be accepted when only 'ecc384' is in accepted list", -+ }, -+ { -+ "algorithm": "ecc", -+ "accepted": ["ecc521"], -+ "expected": False, -+ "desc": "ecc should NOT be accepted when only 'ecc521' is in accepted list", -+ }, -+ ] -+ -+ for c in test_cases: -+ result = is_accepted(c["algorithm"], c["accepted"]) -+ self.assertEqual( -+ result, c["expected"], msg=f"{c['desc']} - algorithm='{c['algorithm']}', accepted={c['accepted']}" -+ ) -+ -+ def test_bidirectional_algorithm_matching(self): -+ """Test bidirectional matching scenarios that happen in real usage""" -+ test_cases = [ -+ { -+ "algorithm": "ecc256", -+ "accepted": ["rsa", "ecc"], -+ "expected": True, -+ "desc": "Agent reports ecc256, tenant config has generic 'ecc'", -+ }, -+ { -+ "algorithm": "ecc", -+ "accepted": ["rsa", "ecc256"], -+ "expected": True, -+ "desc": "Agent reports generic 'ecc', tenant config has specific 'ecc256'", -+ }, -+ { -+ "algorithm": "ecc384", -+ "accepted": ["rsa", "ecc"], -+ "expected": False, -+ "desc": "Agent reports ecc384, tenant has generic 'ecc' (should not match)", -+ }, -+ { -+ "algorithm": "ecc", -+ "accepted": ["rsa", "ecc384"], -+ "expected": False, -+ "desc": "Agent reports generic 'ecc', tenant has ecc384 (should not match)", -+ }, -+ ] -+ -+ for c in test_cases: -+ result = is_accepted(c["algorithm"], c["accepted"]) -+ self.assertEqual( -+ result, c["expected"], msg=f"{c['desc']} - algorithm='{c['algorithm']}', accepted={c['accepted']}" -+ ) -+ -+ def test_mixed_algorithm_types(self): -+ """Test mixing different algorithm types in accepted list""" -+ test_cases = [ -+ { -+ "algorithm": "rsa", -+ "accepted": ["ecc", "rsa"], -+ "expected": True, -+ }, -+ { -+ "algorithm": "ecc256", -+ "accepted": ["rsa", "ecc"], -+ "expected": True, -+ }, -+ { -+ "algorithm": "ecc384", -+ "accepted": ["rsa", "ecc256", "ecc384"], -+ "expected": True, -+ }, -+ { -+ "algorithm": "unknown", -+ "accepted": ["rsa", "ecc", "ecc384"], -+ "expected": False, -+ }, -+ ] -+ -+ for c in test_cases: -+ result = is_accepted(c["algorithm"], c["accepted"]) -+ self.assertEqual(result, c["expected"], msg=f"algorithm='{c['algorithm']}', accepted={c['accepted']}") -+ -+ def test_edge_cases(self): -+ """Test edge cases and boundary conditions""" -+ test_cases = [ -+ {"algorithm": "", "accepted": ["ecc"], "expected": False, "desc": "Empty algorithm string"}, -+ {"algorithm": "ecc256", "accepted": [], "expected": False, "desc": "Empty accepted list"}, -+ {"algorithm": "ecc256", "accepted": [""], "expected": False, "desc": "Accepted list with empty string"}, -+ { -+ "algorithm": "ECC256", -+ "accepted": ["ecc256"], -+ "expected": False, -+ "desc": "Case sensitivity - uppercase should not match", -+ }, -+ { -+ "algorithm": "ecc256", -+ "accepted": ["ecc"], -+ "expected": True, -+ "desc": "ecc256 algorithm should match ecc in accepted list", -+ }, -+ { -+ "algorithm": "ecc", -+ "accepted": ["ecc256"], -+ "expected": True, -+ "desc": "ecc algorithm should match ecc256 in accepted list", -+ }, -+ ] -+ -+ for c in test_cases: -+ result = is_accepted(c["algorithm"], c["accepted"]) -+ self.assertEqual( -+ result, c["expected"], msg=f"{c['desc']} - algorithm='{c['algorithm']}', accepted={c['accepted']}" -+ ) --- -2.47.3 - diff --git a/0017-algorithms-add-support-for-specific-RSA-algorithms.patch b/0017-algorithms-add-support-for-specific-RSA-algorithms.patch deleted file mode 100644 index b081517..0000000 --- a/0017-algorithms-add-support-for-specific-RSA-algorithms.patch +++ /dev/null @@ -1,87 +0,0 @@ -From eecd2f73642f784b19cb1bb9c78c6d0b1e486dda Mon Sep 17 00:00:00 2001 -From: Sergio Correia -Date: Fri, 26 Sep 2025 00:03:49 +0100 -Subject: [PATCH 14/18] algorithms: add support for specific RSA algorithms - -Similar to the previous change for ECC, now we extend the Encrypt enum -to support the following specific RSA algorithms: -- RSA1024 -- RSA2048 -- RSA3072 -- RSA4096 - -Map also 'rsa' to 'rsa2048' for backwards compatibility. - -Signed-off-by: Sergio Correia ---- - keylime/common/algorithms.py | 8 ++++++++ - test/test_algorithms.py | 13 ++++++++++++- - 2 files changed, 20 insertions(+), 1 deletion(-) - -diff --git a/keylime/common/algorithms.py b/keylime/common/algorithms.py -index bb22fb6..32a1ec1 100644 ---- a/keylime/common/algorithms.py -+++ b/keylime/common/algorithms.py -@@ -84,6 +84,10 @@ class Hash(str, enum.Enum): - - class Encrypt(str, enum.Enum): - RSA = "rsa" -+ RSA1024 = "rsa1024" -+ RSA2048 = "rsa2048" -+ RSA3072 = "rsa3072" -+ RSA4096 = "rsa4096" - ECC = "ecc" - ECC192 = "ecc192" - ECC224 = "ecc224" -@@ -96,6 +100,8 @@ class Encrypt(str, enum.Enum): - # Handle aliases to match agent behavior - if algorithm == "ecc": - algorithm = "ecc256" # Default ECC alias maps to P-256, same as the agent. -+ if algorithm == "rsa": -+ algorithm = "rsa2048" # Default RSA alias maps to RSA-2048, same as the agent. - return algorithm in list(Encrypt) - - @staticmethod -@@ -103,6 +109,8 @@ class Encrypt(str, enum.Enum): - """Normalize algorithm string to handle aliases, matching the agent behavior""" - if algorithm == "ecc": - return "ecc256" # Default ECC alias maps to P-256. -+ if algorithm == "rsa": -+ return "rsa2048" # Default RSA alias maps to RSA-2048. - return algorithm - - -diff --git a/test/test_algorithms.py b/test/test_algorithms.py -index 8a31fa9..5542c0f 100644 ---- a/test/test_algorithms.py -+++ b/test/test_algorithms.py -@@ -181,7 +181,7 @@ class TestEncrypt(unittest.TestCase): - }, - { - "input": "rsa", -- "expected": "rsa", -+ "expected": "rsa2048", - }, - ] - -@@ -199,6 +199,17 @@ class TestEncrypt(unittest.TestCase): - # Test that direct ecc256 works - self.assertTrue(Encrypt.is_recognized("ecc256")) - -+ def test_normalize_rsa_alias_behavior(self): -+ """Test that RSA alias normalization matches agent behavior""" -+ # Test that "rsa" is recognized through alias handling -+ self.assertTrue(Encrypt.is_recognized("rsa")) -+ -+ # Test that normalize converts "rsa" to "rsa2048" -+ self.assertEqual(Encrypt.normalize("rsa"), "rsa2048") -+ -+ # Test that direct rsa2048 works -+ self.assertTrue(Encrypt.is_recognized("rsa2048")) -+ - - class TestSign(unittest.TestCase): - def test_is_recognized(self): --- -2.47.3 - diff --git a/0018-tpm_util-fix-quote-signature-extraction-for-ECDSA.patch b/0018-tpm_util-fix-quote-signature-extraction-for-ECDSA.patch deleted file mode 100644 index 9a5480a..0000000 --- a/0018-tpm_util-fix-quote-signature-extraction-for-ECDSA.patch +++ /dev/null @@ -1,43 +0,0 @@ -From 690a2059be01993f5e7f65a01d994e53b82211e4 Mon Sep 17 00:00:00 2001 -From: Thore Sommer -Date: Mon, 3 Mar 2025 15:44:37 +0100 -Subject: [PATCH 15/18] tpm_util: fix quote signature extraction for ECDSA - -Signed-off-by: Thore Sommer ---- - keylime/tpm/tpm_util.py | 12 +++++++++--- - 1 file changed, 9 insertions(+), 3 deletions(-) - -diff --git a/keylime/tpm/tpm_util.py b/keylime/tpm/tpm_util.py -index cdecd32..25c40e0 100644 ---- a/keylime/tpm/tpm_util.py -+++ b/keylime/tpm/tpm_util.py -@@ -223,9 +223,7 @@ def checkquote( - pcrblob: The state of the PCRs that were quoted; Intel tpm2-tools specific format - exp_hash_alg: The hash that was expected to have been used for quoting - """ -- sig_alg, hash_alg, sig_size = struct.unpack_from(">HHH", sigblob, 0) -- -- (signature,) = struct.unpack_from(f"{sig_size}s", sigblob, 6) -+ sig_alg, hash_alg = struct.unpack_from(">HH", sigblob, 0) - - pubkey = serialization.load_pem_public_key(aikblob, backend=backends.default_backend()) - if not isinstance(pubkey, (RSAPublicKey, EllipticCurvePublicKey)): -@@ -236,6 +234,14 @@ def checkquote( - if isinstance(pubkey, EllipticCurvePublicKey) and sig_alg not in [tpm2_objects.TPM_ALG_ECDSA]: - raise ValueError(f"Unsupported quote signature algorithm '{sig_alg:#x}' for EC keys") - -+ if sig_alg in [tpm2_objects.TPM_ALG_RSASSA]: -+ (sig_size,) = struct.unpack_from(">H", sigblob, 4) -+ (signature,) = struct.unpack_from(f"{sig_size}s", sigblob, 6) -+ elif sig_alg in [tpm2_objects.TPM_ALG_ECDSA]: -+ signature = ecdsa_der_from_tpm(sigblob) -+ else: -+ raise ValueError(f"Unsupported quote signature algorithm '{sig_alg:#x}'") -+ - hashfunc = tpm2_objects.HASH_FUNCS.get(hash_alg) - if not hashfunc: - raise ValueError(f"Unsupported hash with id {hash_alg:#x} in signature blob") --- -2.47.3 - diff --git a/0019-tpm-fix-ECC-P-521-coordinate-validation.patch b/0019-tpm-fix-ECC-P-521-coordinate-validation.patch deleted file mode 100644 index e0493ea..0000000 --- a/0019-tpm-fix-ECC-P-521-coordinate-validation.patch +++ /dev/null @@ -1,516 +0,0 @@ -From c0aaf2ad80e2ec714b46ae1ba94678791d58b02d Mon Sep 17 00:00:00 2001 -From: rpm-build -Date: Wed, 4 Feb 2026 01:00:00 +0100 -Subject: [PATCH] tpm: fix ECC P-521 coordinate validation -MIME-Version: 1.0 -Content-Type: text/plain; charset=UTF-8 -Content-Transfer-Encoding: 8bit - -P-521 coordinates can vary from 65-66 bytes due to TPM implementations -padding 521-bit values to byte boundaries or stripping leading zeros. -The previous validation was too strict, rejecting valid coordinates. - -Enhanced validation: -- Accepts P-521 coordinate range 65-66 bytes (520-528 bits) -- Validates against actual NIST prime moduli per SEC1 §2.3.5 and - FIPS 186-4 App D (coordinates must be < field prime p) -- Strict rejection of unknown curves for security - -The enhanced approach prevents false validation of coordinates that are -the correct byte length but exceed the curve's field prime. - -Backported from upstream commit 0219550c3a29db85b202a02b17590260a41a262f - -Signed-off-by: Anderson Toshiyuki Sasaki ---- - keylime/tpm/tpm2_objects.py | 42 +++- - test/test_tpm2_objects.py | 417 ++++++++++++++++++++++++++++++++++++ - 2 files changed, 455 insertions(+), 4 deletions(-) - create mode 100644 test/test_tpm2_objects.py - -diff --git a/keylime/tpm/tpm2_objects.py b/keylime/tpm/tpm2_objects.py -index fcc5bb5..9170628 100644 ---- a/keylime/tpm/tpm2_objects.py -+++ b/keylime/tpm/tpm2_objects.py -@@ -31,6 +31,16 @@ TPM_ECC_NIST_P256 = 0x0003 - TPM_ECC_NIST_P384 = 0x0004 - TPM_ECC_NIST_P521 = 0x0005 - -+# ECC curve prime moduli lookup table (coordinates must be < p) -+# This structure supports NIST curves and can be extended for other curves. -+ECC_CURVE_PRIMES = { -+ TPM_ECC_NIST_P192: 2**192 - 2**64 - 1, # P-192 prime -+ TPM_ECC_NIST_P224: 2**224 - 2**96 + 1, # P-224 prime -+ TPM_ECC_NIST_P256: 2**256 - 2**224 + 2**192 + 2**96 - 1, # P-256 prime -+ TPM_ECC_NIST_P384: 2**384 - 2**128 - 2**96 + 2**32 - 1, # P-384 prime -+ TPM_ECC_NIST_P521: 2**521 - 1, # P-521 prime -+} -+ - TPM_ALG_RSA = 0x0001 - TPM_ALG_ECC = 0x0023 - -@@ -318,10 +328,34 @@ def pubkey_parms_from_tpm2b_public( - if len(rest) != 0: - raise ValueError("Misparsed: more contents after X and Y") - -- if (len(x) * 8) != curve.key_size: -- raise ValueError(f"Misparsed either X or curve: {len(x)}*8 != {curve.key_size}") -- if (len(y) * 8) != curve.key_size: -- raise ValueError(f"Misparsed either Y or curve curve: {len(y)}*8 != {curve.key_size}") -+ # ECC coordinates can vary in byte length due to: -+ # 1. Padding to byte boundaries (most common) -+ # 2. Leading zero stripping in some encodings -+ # Validate both byte length and actual coordinate value. -+ max_bytes = (curve.key_size + 7) // 8 -+ min_bytes = max_bytes - 1 if curve.key_size % 8 != 0 else max_bytes -+ -+ # Get the actual prime modulus for the curve -+ prime_p = ECC_CURVE_PRIMES.get(curve_id) -+ if prime_p is None: -+ raise ValueError(f"Unsupported curve ID {curve_id:#x}: prime modulus not known") -+ -+ for label, coord in (("X", x), ("Y", y)): -+ coord_len = len(coord) -+ if coord_len < min_bytes or coord_len > max_bytes: -+ raise ValueError( -+ f"Misparsed {label} coordinate: got {coord_len} bytes, " -+ f"expected {min_bytes}-{max_bytes} for {curve.key_size}-bit curve" -+ ) -+ -+ coord_int = int.from_bytes(coord, "big") -+ # Coordinates must be reduced modulo the field prime p -+ # (SEC1 §2.3.5, FIPS 186-4 App D). Reject values >= p. -+ if coord_int >= prime_p: -+ raise ValueError( -+ f"{label} coordinate too large: {coord_int.bit_length()} bits, " -+ f"must be < {prime_p.bit_length()}-bit prime modulus" -+ ) - - bx = int.from_bytes(x, byteorder="big") - by = int.from_bytes(y, byteorder="big") -diff --git a/test/test_tpm2_objects.py b/test/test_tpm2_objects.py -new file mode 100644 -index 0000000..9cc6b70 ---- /dev/null -+++ b/test/test_tpm2_objects.py -@@ -0,0 +1,417 @@ -+import struct -+import unittest -+ -+from cryptography.hazmat.primitives.asymmetric import ec -+ -+from keylime.tpm.tpm2_objects import ( -+ ECC_CURVE_PRIMES, -+ TPM_ECC_NIST_P192, -+ TPM_ECC_NIST_P224, -+ TPM_ECC_NIST_P256, -+ TPM_ECC_NIST_P384, -+ TPM_ECC_NIST_P521, -+ _curve_from_curve_id, -+ _pack_in_tpm2b, -+ pubkey_parms_from_tpm2b_public, -+) -+ -+ -+class TestTpm2Objects(unittest.TestCase): -+ def test_p521_coordinate_validation_logic(self): -+ """Test the specific coordinate validation logic for P-521""" -+ curve = _curve_from_curve_id(TPM_ECC_NIST_P521) -+ -+ # Test the updated validation logic -+ max_bytes = (curve.key_size + 7) // 8 # Should be 66 bytes for P-521 -+ min_bytes = max_bytes - 1 if curve.key_size % 8 != 0 else max_bytes # Should be 65 bytes for P-521 -+ -+ self.assertEqual(max_bytes, 66) -+ self.assertEqual(min_bytes, 65) # P-521 is not byte-aligned, so allows 65-66 bytes -+ -+ # Test coordinate sizes that should be accepted (65-66 bytes for P-521) -+ valid_sizes = [65, 66] -+ -+ for size in valid_sizes: -+ # Check that the validation logic would accept this size -+ should_pass = min_bytes <= size <= max_bytes -+ self.assertTrue(should_pass, f"Size {size} bytes should be valid for P-521") -+ -+ # Test coordinate sizes that should be rejected -+ invalid_sizes = [64, 67, 32, 68] -+ -+ for size in invalid_sizes: -+ # This should fail: not in the valid range -+ should_fail = size < min_bytes or size > max_bytes -+ self.assertTrue(should_fail, f"Size {size} bytes should be invalid for P-521") -+ -+ def test_p256_coordinate_validation_logic(self): -+ """Test the coordinate validation logic for P-256 to ensure no regression""" -+ curve = _curve_from_curve_id(TPM_ECC_NIST_P256) -+ -+ max_bytes = (curve.key_size + 7) // 8 # Should be 32 bytes for P-256 -+ min_bytes = ( -+ max_bytes - 1 if curve.key_size % 8 != 0 else max_bytes -+ ) # Should be 32 bytes for P-256 (byte-aligned) -+ -+ self.assertEqual(max_bytes, 32) -+ self.assertEqual(min_bytes, 32) # P-256 is byte-aligned, so only accepts 32 bytes -+ -+ # 32 bytes should be accepted -+ size = 32 -+ should_pass = min_bytes <= size <= max_bytes -+ self.assertTrue(should_pass, f"P-256 should accept {size} bytes") -+ -+ # Other sizes should be rejected -+ invalid_sizes = [31, 33, 64] -+ for size in invalid_sizes: -+ should_fail = size < min_bytes or size > max_bytes -+ self.assertTrue(should_fail, f"P-256 should reject {size} bytes") -+ -+ def test_p384_coordinate_validation_logic(self): -+ """Test the coordinate validation logic for P-384 to ensure no regression""" -+ curve = _curve_from_curve_id(TPM_ECC_NIST_P384) -+ -+ max_bytes = (curve.key_size + 7) // 8 # Should be 48 bytes for P-384 -+ min_bytes = ( -+ max_bytes - 1 if curve.key_size % 8 != 0 else max_bytes -+ ) # Should be 48 bytes for P-384 (byte-aligned) -+ -+ self.assertEqual(max_bytes, 48) -+ self.assertEqual(min_bytes, 48) # P-384 is byte-aligned, so only accepts 48 bytes -+ -+ # 48 bytes should be accepted -+ size = 48 -+ should_pass = min_bytes <= size <= max_bytes -+ self.assertTrue(should_pass, f"P-384 should accept {size} bytes") -+ -+ def test_coordinate_size_calculation(self): -+ """Test that coordinate size calculations are correct for different curves""" -+ # P-256: 256 bits -> (256 + 7) // 8 = 32 bytes -+ curve_p256 = _curve_from_curve_id(TPM_ECC_NIST_P256) -+ expected_p256 = (curve_p256.key_size + 7) // 8 -+ self.assertEqual(expected_p256, 32) -+ self.assertEqual(curve_p256.key_size, 256) -+ -+ # P-384: 384 bits -> (384 + 7) // 8 = 48 bytes -+ curve_p384 = _curve_from_curve_id(TPM_ECC_NIST_P384) -+ expected_p384 = (curve_p384.key_size + 7) // 8 -+ self.assertEqual(expected_p384, 48) -+ self.assertEqual(curve_p384.key_size, 384) -+ -+ # P-521: 521 bits -> (521 + 7) // 8 = 66 bytes -+ curve_p521 = _curve_from_curve_id(TPM_ECC_NIST_P521) -+ expected_p521 = (curve_p521.key_size + 7) // 8 -+ self.assertEqual(expected_p521, 66) -+ self.assertEqual(curve_p521.key_size, 521) -+ -+ def test_p521_specific_fix(self): -+ """Test the specific scenario that was fixed: P-521 with 66-byte coordinates""" -+ curve = _curve_from_curve_id(TPM_ECC_NIST_P521) -+ -+ # The key issue: P-521 has 521 bits -+ self.assertEqual(curve.key_size, 521) -+ -+ # TPMs pad to 66 bytes (528 bits) -+ tpm_padded_size = 66 -+ tpm_padded_bits = tpm_padded_size * 8 -+ self.assertEqual(tpm_padded_bits, 528) -+ -+ # The old validation would reject: (66 * 8) != 521 -+ old_validation_fails = tpm_padded_bits != curve.key_size -+ self.assertTrue(old_validation_fails, "Old validation would incorrectly reject 66-byte coordinates") -+ -+ # The new validation should accept: len(x) == expected_bytes OR (len(x) * 8) == curve.key_size -+ expected_bytes = (curve.key_size + 7) // 8 -+ new_validation_passes = (tpm_padded_size == expected_bytes) or (tpm_padded_bits == curve.key_size) -+ self.assertTrue(new_validation_passes, "New validation should accept 66-byte coordinates") -+ -+ def test_validation_before_and_after_fix(self): -+ """Test that demonstrates the fix by comparing old vs new validation logic""" -+ curve = _curve_from_curve_id(TPM_ECC_NIST_P521) -+ -+ # Test multiple coordinate sizes that P-521 can have -+ test_sizes = [65, 66] # 65 bytes (leading zero stripped), 66 bytes (padded) -+ -+ max_bytes = (curve.key_size + 7) // 8 # 66 bytes -+ min_bytes = max_bytes - 1 if curve.key_size % 8 != 0 else max_bytes # 65 bytes for P-521 -+ -+ for coordinate_size in test_sizes: -+ # Old validation logic (strict bit size match) - would require exactly 65.125 bytes -+ # which is impossible since we can't have fractional bytes -+ -+ # New validation logic (accept range for non-byte-aligned curves) -+ new_logic_passes = min_bytes <= coordinate_size <= max_bytes -+ self.assertTrue(new_logic_passes, f"New logic should accept {coordinate_size}-byte coordinates for P-521") -+ -+ # Verify the calculations -+ self.assertEqual(max_bytes, 66) -+ self.assertEqual(min_bytes, 65) -+ -+ def test_p521_coordinate_range_validation(self): -+ """Test that P-521 accepts coordinates in the range 65-66 bytes (520-528 bits)""" -+ curve = _curve_from_curve_id(TPM_ECC_NIST_P521) -+ -+ # P-521: 521 bits, padded to 66 bytes (528 bits), or 65 bytes with leading zero stripped -+ max_bytes = (curve.key_size + 7) // 8 # 66 bytes -+ min_bytes = max_bytes - 1 # 65 bytes (since 521 % 8 != 0) -+ -+ # Test all valid sizes -+ valid_sizes = [65, 66] -+ for size in valid_sizes: -+ is_valid = min_bytes <= size <= max_bytes -+ self.assertTrue(is_valid, f"P-521 should accept {size} bytes ({size * 8} bits)") -+ -+ # Test invalid sizes -+ invalid_sizes = [64, 67, 68, 32] -+ for size in invalid_sizes: -+ is_invalid = size < min_bytes or size > max_bytes -+ self.assertTrue(is_invalid, f"P-521 should reject {size} bytes ({size * 8} bits)") -+ -+ def test_coordinate_value_validation(self): -+ """Test that coordinate values are validated against actual prime moduli""" -+ # Test P-521 with actual prime -+ # curve_p521 = _curve_from_curve_id(TPM_ECC_NIST_P521) # Not needed for this test -+ p521_prime = ECC_CURVE_PRIMES[TPM_ECC_NIST_P521] -+ -+ # Test valid coordinate value (within range) -+ valid_coord_int = p521_prime - 1 # Largest valid value -+ valid_coord_bytes = valid_coord_int.to_bytes(66, "big") # 66 bytes, padded -+ -+ # Test the validation logic -+ coord_int = int.from_bytes(valid_coord_bytes, "big") -+ is_valid_value = coord_int < p521_prime -+ self.assertTrue(is_valid_value, "Coordinate value should be valid for P-521") -+ -+ # Test invalid coordinate value (>= prime) -+ invalid_coord_int = p521_prime # Equal to prime (invalid) -+ invalid_coord_bytes = invalid_coord_int.to_bytes(66, "big") # 66 bytes, but value too large -+ -+ coord_int = int.from_bytes(invalid_coord_bytes, "big") -+ is_invalid_value = coord_int >= p521_prime -+ self.assertTrue(is_invalid_value, "Coordinate value >= prime should be invalid for P-521") -+ -+ def test_prime_constants_accuracy(self): -+ """Test that our hardcoded prime constants are correct""" -+ # Verify the NIST prime values -+ self.assertEqual(ECC_CURVE_PRIMES[TPM_ECC_NIST_P192], 2**192 - 2**64 - 1) -+ self.assertEqual(ECC_CURVE_PRIMES[TPM_ECC_NIST_P224], 2**224 - 2**96 + 1) -+ self.assertEqual(ECC_CURVE_PRIMES[TPM_ECC_NIST_P256], 2**256 - 2**224 + 2**192 + 2**96 - 1) -+ self.assertEqual(ECC_CURVE_PRIMES[TPM_ECC_NIST_P384], 2**384 - 2**128 - 2**96 + 2**32 - 1) -+ self.assertEqual(ECC_CURVE_PRIMES[TPM_ECC_NIST_P521], 2**521 - 1) -+ -+ # Verify they are actually less than 2^m for all curves except P-521 -+ self.assertLess(ECC_CURVE_PRIMES[TPM_ECC_NIST_P192], 2**192) -+ self.assertLess(ECC_CURVE_PRIMES[TPM_ECC_NIST_P224], 2**224) -+ self.assertLess(ECC_CURVE_PRIMES[TPM_ECC_NIST_P256], 2**256) -+ self.assertLess(ECC_CURVE_PRIMES[TPM_ECC_NIST_P384], 2**384) -+ self.assertEqual(ECC_CURVE_PRIMES[TPM_ECC_NIST_P521], 2**521 - 1) # P-521 is special case -+ -+ def test_prime_lookup_table(self): -+ """Test that the prime lookup table works correctly""" -+ # Test known curves -+ self.assertEqual(ECC_CURVE_PRIMES[TPM_ECC_NIST_P192], 2**192 - 2**64 - 1) -+ self.assertEqual(ECC_CURVE_PRIMES[TPM_ECC_NIST_P224], 2**224 - 2**96 + 1) -+ self.assertEqual(ECC_CURVE_PRIMES[TPM_ECC_NIST_P256], 2**256 - 2**224 + 2**192 + 2**96 - 1) -+ self.assertEqual(ECC_CURVE_PRIMES[TPM_ECC_NIST_P384], 2**384 - 2**128 - 2**96 + 2**32 - 1) -+ self.assertEqual(ECC_CURVE_PRIMES[TPM_ECC_NIST_P521], 2**521 - 1) -+ -+ # Test rejection of unknown curve -+ unknown_curve_id = 0x9999 -+ unknown_prime = ECC_CURVE_PRIMES.get(unknown_curve_id) -+ self.assertIsNone(unknown_prime, "Unknown curves should not be in ECC_CURVE_PRIMES") -+ -+ def test_error_message_formatting(self): -+ """Test that error messages use bit_length() instead of full integers""" -+ # Create a large coordinate value -+ large_value = ECC_CURVE_PRIMES[TPM_ECC_NIST_P521] # This would be hundreds of digits -+ -+ # Verify bit_length() is much more reasonable than the full number -+ bit_length = large_value.bit_length() -+ self.assertEqual(bit_length, 521) # Much more readable than 150+ digit number -+ -+ # The error message should use bit lengths, not full integers -+ expected_msg_pattern = f"coordinate too large: {bit_length} bits" -+ self.assertIn("521 bits", expected_msg_pattern) -+ -+ def test_unknown_curve_rejection(self): -+ """Test that unknown curves are strictly rejected""" -+ # This tests the design decision to be strict rather than use fallbacks -+ unknown_curve_id = 0x9999 -+ -+ # The strict approach: unknown curves should not have fallback behavior -+ # This ensures we only validate curves we explicitly understand -+ result = ECC_CURVE_PRIMES.get(unknown_curve_id) -+ self.assertIsNone(result, "Unknown curves should be explicitly rejected, not given fallback primes") -+ -+ -+class TestEccPublicKeySecurityValidation(unittest.TestCase): -+ """Test that ECC public key validation includes all required security checks: -+ 1. Point is on the curve -+ 2. Point is not zero or infinity -+ 3. Point is not in a small subgroup (not applicable to NIST curves with cofactor=1) -+ """ -+ -+ def create_ecc_tpm2b_public(self, x: int, y: int, curve_id: int = TPM_ECC_NIST_P256) -> bytes: -+ """Helper to create a TPM2B_PUBLIC structure for ECC key with given coordinates""" -+ # Get coordinate size based on curve -+ curve = _curve_from_curve_id(curve_id) -+ coord_bytes = (curve.key_size + 7) // 8 -+ -+ # Convert coordinates to bytes -+ x_bytes = x.to_bytes(coord_bytes, "big") -+ y_bytes = y.to_bytes(coord_bytes, "big") -+ -+ # Build TPMT_PUBLIC structure -+ # alg_type (TPM_ALG_ECC = 0x0023) -+ tpmt = struct.pack(">H", 0x0023) -+ # name_alg (TPM_ALG_SHA256 = 0x000B) -+ tpmt += struct.pack(">H", 0x000B) -+ # object_attributes (4 bytes) -+ tpmt += struct.pack(">I", 0x00040072) -+ # auth_policy (empty TPM2B) -+ tpmt += struct.pack(">H", 0) -+ # symmetric (TPM_ALG_NULL) -+ tpmt += struct.pack(">H", 0x0010) -+ # scheme (TPM_ALG_NULL) -+ tpmt += struct.pack(">H", 0x0010) -+ # curve_id -+ tpmt += struct.pack(">H", curve_id) -+ # kdf_scheme (TPM_ALG_NULL) -+ tpmt += struct.pack(">H", 0x0010) -+ # x coordinate (TPM2B) -+ tpmt += _pack_in_tpm2b(x_bytes) -+ # y coordinate (TPM2B) -+ tpmt += _pack_in_tpm2b(y_bytes) -+ -+ # Wrap in TPM2B_PUBLIC -+ return _pack_in_tpm2b(tpmt) -+ -+ def test_point_on_curve_validation(self): -+ """Test that points not on the curve are rejected (Security Check #1)""" -+ # For P-256, the curve equation is: y² = x³ - 3x + b (mod p) -+ # Choose coordinates that don't satisfy this equation -+ x = 1 -+ y = 1 # (1, 1) is not on the P-256 curve -+ -+ tpm2b_public = self.create_ecc_tpm2b_public(x, y, TPM_ECC_NIST_P256) -+ -+ # The cryptography library should reject this point as not being on the curve -+ with self.assertRaises(ValueError) as cm: -+ pubkey_parms_from_tpm2b_public(tpm2b_public) -+ self.assertIn("invalid ec key", str(cm.exception).lower()) -+ -+ def test_point_at_infinity_validation(self): -+ """Test that the point at infinity (0, 0) is rejected (Security Check #2)""" -+ # The point at infinity should be rejected -+ x = 0 -+ y = 0 -+ -+ tpm2b_public = self.create_ecc_tpm2b_public(x, y, TPM_ECC_NIST_P256) -+ -+ # The cryptography library should reject the point at infinity -+ with self.assertRaises(ValueError) as cm: -+ pubkey_parms_from_tpm2b_public(tpm2b_public) -+ self.assertIn("invalid ec key", str(cm.exception).lower()) -+ -+ def test_valid_point_accepted(self): -+ """Test that a valid point on the curve is accepted""" -+ # Generate a valid key and extract its coordinates -+ private_key = ec.generate_private_key(ec.SECP256R1()) -+ public_key = private_key.public_key() -+ numbers = public_key.public_numbers() -+ -+ # Create TPM2B_PUBLIC with valid coordinates -+ tpm2b_public = self.create_ecc_tpm2b_public(numbers.x, numbers.y, TPM_ECC_NIST_P256) -+ -+ # Should parse successfully -+ parsed_key, _ = pubkey_parms_from_tpm2b_public(tpm2b_public) -+ self.assertIsInstance(parsed_key, ec.EllipticCurvePublicKey) -+ -+ # Verify the coordinates match -+ assert isinstance(parsed_key, ec.EllipticCurvePublicKey) # Type narrowing for pyright -+ parsed_numbers = parsed_key.public_numbers() -+ self.assertEqual(parsed_numbers.x, numbers.x) -+ self.assertEqual(parsed_numbers.y, numbers.y) -+ -+ def test_small_subgroup_not_applicable_to_nist_curves(self): -+ """Test documenting that small subgroup checks are not needed for NIST curves (Security Check #3) -+ -+ NIST P-curves (P-192, P-224, P-256, P-384, P-521) all have cofactor h=1, -+ meaning the entire curve has prime order. There are no small subgroups to check. -+ -+ Curves with cofactor > 1 (like Curve25519 with h=8) require additional validation -+ to ensure the point is not in a small subgroup, but this is not applicable to -+ the NIST curves used by TPMs. -+ """ -+ # This test documents the cofactor=1 property for all supported NIST curves -+ # The cryptography library's point validation is sufficient for these curves -+ -+ test_curves = [ -+ (TPM_ECC_NIST_P192, ec.SECP192R1()), -+ (TPM_ECC_NIST_P224, ec.SECP224R1()), -+ (TPM_ECC_NIST_P256, ec.SECP256R1()), -+ (TPM_ECC_NIST_P384, ec.SECP384R1()), -+ (TPM_ECC_NIST_P521, ec.SECP521R1()), -+ ] -+ -+ for curve_id, curve_obj in test_curves: -+ with self.subTest(curve=curve_obj.name): -+ try: -+ # Generate a valid key for this curve -+ # Note: P-192 may not be supported in newer OpenSSL versions -+ private_key = ec.generate_private_key(curve_obj) -+ except Exception: # pylint: disable=broad-except -+ # Skip this specific curve if not supported by OpenSSL (e.g., P-192) -+ self.skipTest(f"Curve {curve_obj.name} not supported by OpenSSL") -+ -+ public_key = private_key.public_key() -+ numbers = public_key.public_numbers() -+ -+ # Create TPM2B_PUBLIC and verify it parses successfully -+ tpm2b_public = self.create_ecc_tpm2b_public(numbers.x, numbers.y, curve_id) -+ parsed_key, _ = pubkey_parms_from_tpm2b_public(tpm2b_public) -+ -+ # All NIST curves have cofactor = 1, so no small subgroup attacks possible -+ # The point validation by the cryptography library is sufficient -+ self.assertIsInstance(parsed_key, ec.EllipticCurvePublicKey) -+ -+ def test_coordinate_exceeds_field_prime_rejected(self): -+ """Test that coordinates >= field prime are rejected""" -+ # Use a coordinate value that's >= the field prime for P-256 -+ p256_prime = ECC_CURVE_PRIMES[TPM_ECC_NIST_P256] -+ -+ # x coordinate exceeds the field prime -+ x = p256_prime + 1 -+ y = 1 -+ -+ tpm2b_public = self.create_ecc_tpm2b_public(x, y, TPM_ECC_NIST_P256) -+ -+ # Should be rejected during coordinate validation -+ with self.assertRaises(ValueError) as cm: -+ pubkey_parms_from_tpm2b_public(tpm2b_public) -+ # Will fail either at coordinate validation or curve validation -+ self.assertTrue( -+ "coordinate too large" in str(cm.exception).lower() or "invalid ec key" in str(cm.exception).lower() -+ ) -+ -+ def test_p521_point_validation(self): -+ """Test point validation works correctly for P-521 (non-byte-aligned curve)""" -+ # Generate a valid P-521 key -+ private_key = ec.generate_private_key(ec.SECP521R1()) -+ public_key = private_key.public_key() -+ numbers = public_key.public_numbers() -+ -+ # Valid point should be accepted -+ tpm2b_public = self.create_ecc_tpm2b_public(numbers.x, numbers.y, TPM_ECC_NIST_P521) -+ parsed_key, _ = pubkey_parms_from_tpm2b_public(tpm2b_public) -+ self.assertIsInstance(parsed_key, ec.EllipticCurvePublicKey) -+ -+ # Invalid point should be rejected -+ tpm2b_public_invalid = self.create_ecc_tpm2b_public(1, 1, TPM_ECC_NIST_P521) -+ with self.assertRaises(ValueError) as cm: -+ pubkey_parms_from_tpm2b_public(tpm2b_public_invalid) -+ self.assertIn("invalid ec key", str(cm.exception).lower()) -+ -+ -+if __name__ == "__main__": -+ unittest.main() --- -2.52.0 - diff --git a/0020-tpm-fix-ECC-P-521-credential-activation-with-consist.patch b/0020-tpm-fix-ECC-P-521-credential-activation-with-consist.patch deleted file mode 100644 index 176ba07..0000000 --- a/0020-tpm-fix-ECC-P-521-credential-activation-with-consist.patch +++ /dev/null @@ -1,222 +0,0 @@ -From a4c32b7a84c93df86284b95e735923beeb18ca94 Mon Sep 17 00:00:00 2001 -From: rpm-build -Date: Wed, 4 Feb 2026 18:58:40 +0100 -Subject: [PATCH] tpm: fix ECC P-521 credential activation with consistent - marshaling - -The TPM credential activation was failing for P-521 curves due to -inconsistent ECC point marshaling in tpms_ecc_point_marshal(). - -The function used bit_length() which varies for P-521 coordinates -(520-521 bits), producing different blob sizes and causing TPM -integrity check failures during ActivateCredential operations. - -Backported from upstream commit 1db525b7abf62e6d2d9450817477fd0911e083d4 - -Signed-off-by: Anderson Toshiyuki Sasaki ---- - keylime/tpm/tpm2_objects.py | 11 +-- - keylime/tpm/tpm_util.py | 7 +- - test/test_tpm2_objects.py | 130 ++++++++++++++++++++++++++++++++++++ - 3 files changed, 142 insertions(+), 6 deletions(-) - -diff --git a/keylime/tpm/tpm2_objects.py b/keylime/tpm/tpm2_objects.py -index 9170628..d33ebaa 100644 ---- a/keylime/tpm/tpm2_objects.py -+++ b/keylime/tpm/tpm2_objects.py -@@ -597,9 +597,12 @@ def unmarshal_tpml_pcr_selection(tpml_pcr_selection: bytes) -> Tuple[Dict[int, i - - def tpms_ecc_point_marshal(public_key: EllipticCurvePublicKey) -> bytes: - pn = public_key.public_numbers() -+ curve = public_key.curve - -- sz = (pn.x.bit_length() + 7) // 8 -- secret = struct.pack(">H", sz) + pn.x.to_bytes(sz, "big") -+ # Use fixed coordinate size based on curve to ensure consistent marshaling -+ # This is critical for P-521 where bit_length() can vary (520-521 bits) -+ # leading to credential activation failures due to inconsistent blob sizes -+ coord_size = (curve.key_size + 7) // 8 - -- sz = (pn.y.bit_length() + 7) // 8 -- return secret + struct.pack(">H", sz) + pn.y.to_bytes(sz, "big") -+ secret = struct.pack(">H", coord_size) + pn.x.to_bytes(coord_size, "big") -+ return secret + struct.pack(">H", coord_size) + pn.y.to_bytes(coord_size, "big") -diff --git a/keylime/tpm/tpm_util.py b/keylime/tpm/tpm_util.py -index 25c40e0..fbbe557 100644 ---- a/keylime/tpm/tpm_util.py -+++ b/keylime/tpm/tpm_util.py -@@ -318,11 +318,14 @@ def crypt_secret_encrypt_ecc(public_key: EllipticCurvePublicKey, hashfunc: hashe - - digest_size = hashfunc.digest_size - -+ # Use fixed coordinate size for consistent marshaling -+ coord_size = (public_key.curve.key_size + 7) // 8 -+ - x = my_public_key.public_numbers().x -- party_x = x.to_bytes((x.bit_length() + 7) >> 3, "big") -+ party_x = x.to_bytes(coord_size, "big") - - x = public_key.public_numbers().x -- party_y = x.to_bytes((x.bit_length() + 7) >> 3, "big") -+ party_y = x.to_bytes(coord_size, "big") - - data = crypt_kdfe(hashfunc, ecc_secret_x, "IDENTITY", party_x, party_y, digest_size << 3) - -diff --git a/test/test_tpm2_objects.py b/test/test_tpm2_objects.py -index 9cc6b70..aa91f8d 100644 ---- a/test/test_tpm2_objects.py -+++ b/test/test_tpm2_objects.py -@@ -1,6 +1,7 @@ - import struct - import unittest - -+from cryptography.hazmat.primitives import hashes - from cryptography.hazmat.primitives.asymmetric import ec - - from keylime.tpm.tpm2_objects import ( -@@ -13,7 +14,9 @@ from keylime.tpm.tpm2_objects import ( - _curve_from_curve_id, - _pack_in_tpm2b, - pubkey_parms_from_tpm2b_public, -+ tpms_ecc_point_marshal, - ) -+from keylime.tpm.tpm_util import crypt_secret_encrypt_ecc - - - class TestTpm2Objects(unittest.TestCase): -@@ -413,5 +416,132 @@ class TestEccPublicKeySecurityValidation(unittest.TestCase): - self.assertIn("invalid ec key", str(cm.exception).lower()) - - -+class TestEccMarshaling(unittest.TestCase): -+ """Test ECC point marshaling consistency fixes""" -+ -+ def test_p521_marshaling_consistency(self): -+ """Test that P-521 marshaling produces consistent blob sizes regardless of coordinate values""" -+ # Generate multiple P-521 keys to test with different coordinate values -+ keys = [] -+ for _ in range(10): -+ private_key = ec.generate_private_key(ec.SECP521R1()) -+ keys.append(private_key.public_key()) -+ -+ # Marshal all keys and check that blob sizes are consistent -+ blob_sizes = [] -+ for key in keys: -+ blob = tpms_ecc_point_marshal(key) -+ blob_sizes.append(len(blob)) -+ -+ # All blobs should be the same size for P-521 -+ self.assertEqual(len(set(blob_sizes)), 1, "All P-521 marshaled blobs should have the same size") -+ -+ # Expected size: 2 bytes (x size) + 66 bytes (x coord) + 2 bytes (y size) + 66 bytes (y coord) = 136 bytes -+ expected_size = 2 + 66 + 2 + 66 -+ self.assertEqual(blob_sizes[0], expected_size, f"P-521 marshaled blob should be {expected_size} bytes") -+ -+ def test_marshaling_coordinate_sizes(self): -+ """Test that marshaled coordinates use fixed sizes based on curve key size""" -+ # Test P-521: 521 bits -> (521 + 7) // 8 = 66 bytes per coordinate -+ p521_key = ec.generate_private_key(ec.SECP521R1()).public_key() -+ p521_blob = tpms_ecc_point_marshal(p521_key) -+ -+ # Parse the blob to check coordinate sizes -+ x_size = struct.unpack(">H", p521_blob[:2])[0] -+ y_size = struct.unpack(">H", p521_blob[2 + x_size : 2 + x_size + 2])[0] -+ -+ self.assertEqual(x_size, 66, "P-521 X coordinate should be 66 bytes") -+ self.assertEqual(y_size, 66, "P-521 Y coordinate should be 66 bytes") -+ -+ # Test P-256: 256 bits -> (256 + 7) // 8 = 32 bytes per coordinate -+ p256_key = ec.generate_private_key(ec.SECP256R1()).public_key() -+ p256_blob = tpms_ecc_point_marshal(p256_key) -+ -+ x_size = struct.unpack(">H", p256_blob[:2])[0] -+ y_size = struct.unpack(">H", p256_blob[2 + x_size : 2 + x_size + 2])[0] -+ -+ self.assertEqual(x_size, 32, "P-256 X coordinate should be 32 bytes") -+ self.assertEqual(y_size, 32, "P-256 Y coordinate should be 32 bytes") -+ -+ def test_p521_credential_activation_consistency(self): -+ """Test the specific issue: P-521 credential activation with consistent marshaling""" -+ # This test verifies the fix for credential activation failures -+ # Generate two P-521 keys with potentially different bit lengths for coordinates -+ key1 = ec.generate_private_key(ec.SECP521R1()).public_key() -+ key2 = ec.generate_private_key(ec.SECP521R1()).public_key() -+ -+ # Marshal both keys -+ blob1 = tpms_ecc_point_marshal(key1) -+ blob2 = tpms_ecc_point_marshal(key2) -+ -+ # The critical fix: both blobs should be the same size regardless of coordinate bit lengths -+ self.assertEqual( -+ len(blob1), len(blob2), "P-521 marshaled blobs must be same size regardless of coordinate bit lengths" -+ ) -+ -+ # Both should use the fixed coordinate size (66 bytes) -+ expected_total_size = 2 + 66 + 2 + 66 # size_x + x + size_y + y -+ self.assertEqual(len(blob1), expected_total_size) -+ self.assertEqual(len(blob2), expected_total_size) -+ -+ def test_marshaling_format_correctness(self): -+ """Test that marshaling follows the correct TPM format: size(2) + coord(n) + size(2) + coord(n)""" -+ key = ec.generate_private_key(ec.SECP521R1()).public_key() -+ blob = tpms_ecc_point_marshal(key) -+ -+ # Parse the blob structure -+ if len(blob) < 4: -+ self.fail("Marshaled blob too short") -+ -+ x_size = struct.unpack(">H", blob[:2])[0] -+ self.assertEqual(x_size, 66, "X coordinate size should be 66 for P-521") -+ -+ if len(blob) < 2 + x_size + 2: -+ self.fail("Marshaled blob missing Y coordinate size") -+ -+ y_size = struct.unpack(">H", blob[2 + x_size : 2 + x_size + 2])[0] -+ self.assertEqual(y_size, 66, "Y coordinate size should be 66 for P-521") -+ -+ # Total size should be: 2 + 66 + 2 + 66 = 136 -+ expected_total = 2 + x_size + 2 + y_size -+ self.assertEqual(len(blob), expected_total, "Total marshaled blob size incorrect") -+ -+ def test_crypt_secret_encrypt_ecc_consistency(self): -+ """Test that crypt_secret_encrypt_ecc produces consistent results with fixed coordinate sizes""" -+ # Generate a P-521 key to test with -+ public_key = ec.generate_private_key(ec.SECP521R1()).public_key() -+ hashfunc = hashes.SHA256() -+ -+ # Call the function multiple times and check consistency -+ results = [] -+ for _ in range(5): -+ data, point = crypt_secret_encrypt_ecc(public_key, hashfunc) -+ results.append((data, point)) -+ -+ # Check that all returned points have consistent marshaling -+ # (the data will be different due to random key generation, but point marshaling should be consistent) -+ point_sizes = [len(point) for _, point in results] -+ self.assertEqual(len(set(point_sizes)), 1, "All marshaled points should have the same size") -+ -+ # For P-521, the marshaled point should be 136 bytes (2+66+2+66) -+ expected_point_size = 2 + 66 + 2 + 66 -+ self.assertEqual( -+ point_sizes[0], expected_point_size, f"P-521 marshaled point should be {expected_point_size} bytes" -+ ) -+ -+ # All data results should be different (due to random ephemeral keys) -+ data_results = [data for data, _ in results] -+ self.assertEqual( -+ len(set(data_results)), -+ len(data_results), -+ "All data results should be different due to random ephemeral keys", -+ ) -+ -+ # All data results should have the same length (SHA256 digest size) -+ data_sizes = [len(data) for data, _ in results] -+ self.assertEqual(len(set(data_sizes)), 1, "All data results should have the same size") -+ self.assertEqual(data_sizes[0], hashfunc.digest_size, "Data size should match hash digest size") -+ -+ - if __name__ == "__main__": - unittest.main() --- -2.52.0 - diff --git a/0021-tpm-fix-ECC-signature-parsing-to-support-variable-le.patch b/0021-tpm-fix-ECC-signature-parsing-to-support-variable-le.patch deleted file mode 100644 index fbe8cbb..0000000 --- a/0021-tpm-fix-ECC-signature-parsing-to-support-variable-le.patch +++ /dev/null @@ -1,372 +0,0 @@ -From ee4192df70384fa6b23f359a287e042103ba4ea9 Mon Sep 17 00:00:00 2001 -From: Sergio Correia -Date: Thu, 25 Sep 2025 14:37:10 +0100 -Subject: [PATCH 18/18] tpm: fix ECC signature parsing to support - variable-length coordinates - -The previous ECC signature validation implementation incorrectly assumed -fixed-length coordinate encoding, causing failures with mathematically -correct variable-length coordinates (especially P-521 curves where -coordinates are typically 65-66 bytes). - -This commit fixes the ecdsa_der_from_tpm() function to properly handle -TSS ESAPI signature format where the sig_size field contains only the -r component size, followed by the s component with its own size header. - -This enables proper ECC attestation for the supported NIST curves. - -Assisted-by: Claude 4 Sonnet -Signed-off-by: Sergio Correia ---- - keylime/tpm/tpm_main.py | 2 +- - keylime/tpm/tpm_util.py | 105 +++++++++++++++++++--- - test/test_tpm2_objects.py | 184 +++++++++++++++++++++++++++++++++++++- - 3 files changed, 277 insertions(+), 14 deletions(-) - -diff --git a/keylime/tpm/tpm_main.py b/keylime/tpm/tpm_main.py -index 6f2e89f..ecbacbe 100644 ---- a/keylime/tpm/tpm_main.py -+++ b/keylime/tpm/tpm_main.py -@@ -91,7 +91,7 @@ class Tpm: - if isinstance(iak_pub, EllipticCurvePublicKey): - if sig_alg in [tpm2_objects.TPM_ALG_ECDSA]: - try: -- der_sig = tpm_util.ecdsa_der_from_tpm(iak_sign) -+ der_sig = tpm_util.ecdsa_der_from_tpm(iak_sign, iak_pub) - tpm_util.verify(iak_pub, der_sig, digest, hashfunc) - logger.info("Agent %s AIK verified with IAK", uuid) - return True -diff --git a/keylime/tpm/tpm_util.py b/keylime/tpm/tpm_util.py -index fbbe557..f554f94 100644 ---- a/keylime/tpm/tpm_util.py -+++ b/keylime/tpm/tpm_util.py -@@ -59,6 +59,43 @@ logger = keylime_logging.init_logging("tpm_util") - - SupportedKeyTypes = Union[RSAPublicKey, EllipticCurvePublicKey] - -+# ECC signature parsing constants. -+# Raw signature sizes for different ECC curves (r||s concatenated format). -+# r and s are the two mathematical components of an ECDSA signature. -+ECC_SECP192R1_SIGNATURE_SIZE = 48 # 24 bytes each for r,s. -+ECC_SECP224R1_SIGNATURE_SIZE = 56 # 28 bytes each for r,s. -+ECC_SECP256R1_SIGNATURE_SIZE = 64 # 32 bytes each for r,s. -+ECC_SECP384R1_SIGNATURE_SIZE = 96 # 48 bytes each for r,s. -+ECC_SECP521R1_SIGNATURE_SIZE = 132 # 66 bytes each for r,s. -+ -+# TPM2B_ECDSA_SIGNATURE format constants. -+TPM2B_SIZE_FIELD_LENGTH = 2 # 2 bytes for size field. -+TPM2B_MIN_HEADER_SIZE = 4 # Minimum: 2 bytes r_size + 2 bytes s_size. -+ -+# DER encoding constants. -+DER_SEQUENCE_TAG = 0x30 -+ -+# Signature blob header offset (skip alg, hash_alg, sig_size headers). -+SIGNATURE_BLOB_HEADER_SIZE = 6 -+ -+# ECC curve to signature size mapping for raw r||s format. -+ECC_RAW_SIGNATURE_SIZES = { -+ "secp192r1": ECC_SECP192R1_SIGNATURE_SIZE, -+ "secp224r1": ECC_SECP224R1_SIGNATURE_SIZE, -+ "secp256r1": ECC_SECP256R1_SIGNATURE_SIZE, -+ "secp384r1": ECC_SECP384R1_SIGNATURE_SIZE, -+ "secp521r1": ECC_SECP521R1_SIGNATURE_SIZE, -+} -+ -+# ECC curve coordinate size ranges (min, max) for validation. -+ECC_COORDINATE_SIZE_RANGES = { -+ "secp192r1": (1, 24), # 192 bits = 24 bytes max -+ "secp224r1": (1, 28), # 224 bits = 28 bytes max -+ "secp256r1": (1, 32), # 256 bits = 32 bytes max -+ "secp384r1": (1, 48), # 384 bits = 48 bytes max -+ "secp521r1": (1, 66), # 521 bits = 66 bytes max (521 bits, not 512) -+} -+ - - def verify( - pubkey: SupportedKeyTypes, -@@ -106,17 +143,59 @@ def der_len(encoded_int_len: int) -> bytes: - return bytes((0x80 | len(bin_str),)) + bin_str - - --def ecdsa_der_from_tpm(sigblob: bytes) -> bytes: -- _, _, sig_size_r = struct.unpack_from(">HHH", sigblob, 0) -- sig_r = sigblob[6 : 6 + sig_size_r] -- encoded_sig_r = der_int(sig_r) -- sigblob = sigblob[6 + sig_size_r :] -- sig_size_s = struct.unpack_from(">H", sigblob, 0)[0] -- sig_s = sigblob[2 : 2 + sig_size_s] -- encoded_sig_s = der_int(sig_s) -- total_size = len(encoded_sig_r) + len(encoded_sig_s) -- der_sig = bytes.fromhex(f"30{total_size:x}") + encoded_sig_r + encoded_sig_s -- return der_sig -+def ecdsa_der_from_tpm(sigblob: bytes, pubkey: EllipticCurvePublicKey) -> bytes: -+ """Convert ECC signature from TPM format to DER format for cryptographic verification. -+ -+ This function handles TSS ESAPI signature format where the signature header's -+ sig_size field contains the size of the r component, followed by the s component -+ with its own size header. -+ -+ Parameters -+ ---------- -+ sigblob: TPM signature blob containing signature headers and signature data -+ pubkey: ECC public key to determine expected signature size -+ -+ Returns -+ ------- -+ DER-encoded ECDSA signature suitable for cryptographic library verification -+ -+ Raises -+ ------ -+ ValueError: If signature format cannot be parsed or is invalid -+ """ -+ # Extract signature header information. -+ _sig_alg, _hash_alg, sig_size_r = struct.unpack_from(">HHH", sigblob, 0) -+ -+ # Extract the r component (size is in sig_size_r field). -+ sig_r = sigblob[SIGNATURE_BLOB_HEADER_SIZE : SIGNATURE_BLOB_HEADER_SIZE + sig_size_r] -+ -+ # The s component follows immediately after r, with its own size header. -+ s_offset = SIGNATURE_BLOB_HEADER_SIZE + sig_size_r -+ if s_offset + 2 <= len(sigblob): -+ sig_size_s = struct.unpack_from(">H", sigblob, s_offset)[0] -+ s_start = s_offset + 2 -+ if s_start + sig_size_s <= len(sigblob): -+ sig_s = sigblob[s_start : s_start + sig_size_s] -+ -+ # Validate coordinate sizes against curve requirements. -+ curve_name = pubkey.curve.name -+ coordinate_range = ECC_COORDINATE_SIZE_RANGES.get(curve_name) -+ if coordinate_range: -+ min_size, max_size = coordinate_range -+ if not min_size <= len(sig_r) <= max_size: -+ raise ValueError(f"Invalid r coordinate size {len(sig_r)} for curve {curve_name}") -+ if not min_size <= len(sig_s) <= max_size: -+ raise ValueError(f"Invalid s coordinate size {len(sig_s)} for curve {curve_name}") -+ -+ # Convert to DER format. -+ encoded_sig_r = der_int(sig_r) -+ encoded_sig_s = der_int(sig_s) -+ total_size = len(encoded_sig_r) + len(encoded_sig_s) -+ der_length = der_len(total_size) -+ der_sig = bytes([DER_SEQUENCE_TAG]) + der_length + encoded_sig_r + encoded_sig_s -+ return der_sig -+ -+ raise ValueError("Unable to parse ECC signature from TPM format") - - - def __get_pcrs_from_blob(pcrblob: bytes) -> Tuple[int, Dict[int, int], List[bytes]]: -@@ -238,7 +317,9 @@ def checkquote( - (sig_size,) = struct.unpack_from(">H", sigblob, 4) - (signature,) = struct.unpack_from(f"{sig_size}s", sigblob, 6) - elif sig_alg in [tpm2_objects.TPM_ALG_ECDSA]: -- signature = ecdsa_der_from_tpm(sigblob) -+ if not isinstance(pubkey, EllipticCurvePublicKey): -+ raise ValueError(f"ECDSA signature algorithm requires EllipticCurvePublicKey, got {type(pubkey)}") -+ signature = ecdsa_der_from_tpm(sigblob, pubkey) - else: - raise ValueError(f"Unsupported quote signature algorithm '{sig_alg:#x}'") - -diff --git a/test/test_tpm2_objects.py b/test/test_tpm2_objects.py -index 48d6a43..c0e4c0a 100644 ---- a/test/test_tpm2_objects.py -+++ b/test/test_tpm2_objects.py -@@ -16,7 +16,7 @@ from keylime.tpm.tpm2_objects import ( - pubkey_parms_from_tpm2b_public, - tpms_ecc_point_marshal, - ) --from keylime.tpm.tpm_util import crypt_secret_encrypt_ecc -+from keylime.tpm.tpm_util import crypt_secret_encrypt_ecc, der_int, der_len, ecdsa_der_from_tpm - - - class TestTpm2Objects(unittest.TestCase): -@@ -543,5 +543,187 @@ class TestEccMarshaling(unittest.TestCase): - self.assertEqual(data_sizes[0], hashfunc.digest_size, "Data size should match hash digest size") - - -+class TestEccSignatureParsing(unittest.TestCase): -+ """Test ECC signature parsing improvements for variable-length coordinates""" -+ -+ def create_test_signature_blob(self, sig_r: bytes, sig_s: bytes) -> bytes: -+ """Create a test TPM signature blob with given r and s components""" -+ # TPM signature format: sig_alg(2) + hash_alg(2) + sig_size_r(2) + r_data + sig_size_s(2) + s_data -+ sig_alg = 0x0018 # TPM_ALG_ECDSA -+ hash_alg = 0x000B # TPM_ALG_SHA256 -+ -+ blob = struct.pack(">HHH", sig_alg, hash_alg, len(sig_r)) -+ blob += sig_r -+ blob += struct.pack(">H", len(sig_s)) -+ blob += sig_s -+ -+ return blob -+ -+ def test_p521_variable_length_coordinates(self): -+ """Test that P-521 signatures with variable-length coordinates are parsed correctly""" -+ # Generate a P-521 key for testing -+ private_key = ec.generate_private_key(ec.SECP521R1()) -+ public_key = private_key.public_key() -+ -+ # Test with 65-byte coordinates (leading zero stripped) -+ sig_r_65 = b"\x00" * 1 + b"\x01" * 64 # 65 bytes -+ sig_s_65 = b"\x00" * 1 + b"\x02" * 64 # 65 bytes -+ -+ blob_65 = self.create_test_signature_blob(sig_r_65, sig_s_65) -+ -+ # Should parse successfully -+ der_sig_65 = ecdsa_der_from_tpm(blob_65, public_key) -+ self.assertIsInstance(der_sig_65, bytes) -+ self.assertTrue(len(der_sig_65) > 0) -+ -+ # Test with 66-byte coordinates (full padding) -+ sig_r_66 = b"\x00" * 2 + b"\x01" * 64 # 66 bytes -+ sig_s_66 = b"\x00" * 2 + b"\x02" * 64 # 66 bytes -+ -+ blob_66 = self.create_test_signature_blob(sig_r_66, sig_s_66) -+ -+ # Should parse successfully -+ der_sig_66 = ecdsa_der_from_tpm(blob_66, public_key) -+ self.assertIsInstance(der_sig_66, bytes) -+ self.assertTrue(len(der_sig_66) > 0) -+ -+ def test_coordinate_size_validation(self): -+ """Test that coordinate size validation works for different curves""" -+ # Test P-256 with valid coordinates -+ p256_key = ec.generate_private_key(ec.SECP256R1()).public_key() -+ -+ # Valid P-256 coordinates (32 bytes each) -+ sig_r_32 = b"\x01" * 32 -+ sig_s_32 = b"\x02" * 32 -+ blob_p256_valid = self.create_test_signature_blob(sig_r_32, sig_s_32) -+ -+ # Should parse successfully -+ der_sig = ecdsa_der_from_tpm(blob_p256_valid, p256_key) -+ self.assertIsInstance(der_sig, bytes) -+ -+ # Test P-256 with invalid coordinates (too large) -+ sig_r_invalid = b"\x01" * 50 # Too large for P-256 -+ sig_s_invalid = b"\x02" * 50 # Too large for P-256 -+ blob_p256_invalid = self.create_test_signature_blob(sig_r_invalid, sig_s_invalid) -+ -+ # Should raise ValueError -+ with self.assertRaises(ValueError) as cm: -+ ecdsa_der_from_tpm(blob_p256_invalid, p256_key) -+ self.assertIn("Invalid r coordinate size", str(cm.exception)) -+ -+ def test_signature_parsing_edge_cases(self): -+ """Test edge cases in signature parsing""" -+ p256_key = ec.generate_private_key(ec.SECP256R1()).public_key() -+ -+ # Test with truncated blob (missing s component) -+ truncated_blob = struct.pack(">HHH", 0x0018, 0x000B, 32) + b"\x01" * 32 -+ # Missing s component -+ -+ with self.assertRaises(ValueError) as cm: -+ ecdsa_der_from_tpm(truncated_blob, p256_key) -+ self.assertIn("Unable to parse ECC signature", str(cm.exception)) -+ -+ # Test with blob too short for s size header -+ short_blob = struct.pack(">HHH", 0x0018, 0x000B, 32) + b"\x01" * 32 + b"\x00" # Only 1 byte for s size -+ -+ with self.assertRaises(ValueError) as cm: -+ ecdsa_der_from_tpm(short_blob, p256_key) -+ self.assertIn("Unable to parse ECC signature", str(cm.exception)) -+ -+ def test_der_encoding_correctness(self): -+ """Test that DER encoding produces correctly formatted output""" -+ p256_key = ec.generate_private_key(ec.SECP256R1()).public_key() -+ -+ # Create test coordinates -+ sig_r = b"\x01" * 32 -+ sig_s = b"\x02" * 32 -+ blob = self.create_test_signature_blob(sig_r, sig_s) -+ -+ der_sig = ecdsa_der_from_tpm(blob, p256_key) -+ -+ # DER signature should start with SEQUENCE tag (0x30) -+ self.assertEqual(der_sig[0], 0x30, "DER signature should start with SEQUENCE tag") -+ -+ # Should be parseable as DER format -+ # The structure should be: 0x30 + length + INTEGER(r) + INTEGER(s) -+ self.assertTrue(len(der_sig) >= 6, "DER signature should have minimum length") -+ -+ def test_multiple_curve_support(self): -+ """Test that signature parsing works for multiple curve types""" -+ test_cases = [ -+ (ec.SECP256R1(), 32), -+ (ec.SECP384R1(), 48), -+ (ec.SECP521R1(), 66), -+ ] -+ -+ for curve, coord_size in test_cases: -+ with self.subTest(curve=curve.name): -+ private_key = ec.generate_private_key(curve) -+ public_key = private_key.public_key() -+ -+ # Create test signature with appropriate coordinate size -+ sig_r = b"\x01" * coord_size -+ sig_s = b"\x02" * coord_size -+ blob = self.create_test_signature_blob(sig_r, sig_s) -+ -+ # Should parse successfully -+ der_sig = ecdsa_der_from_tpm(blob, public_key) -+ self.assertIsInstance(der_sig, bytes) -+ self.assertTrue(len(der_sig) > 0) -+ self.assertEqual(der_sig[0], 0x30) # DER SEQUENCE tag -+ -+ def test_der_int_encoding(self): -+ """Test DER integer encoding helper function""" -+ # Test positive number that doesn't need padding -+ test_bytes = b"\x7F" # 127, no padding needed -+ der_encoded = der_int(test_bytes) -+ expected = b"\x02\x01\x7F" # INTEGER tag + length + value -+ self.assertEqual(der_encoded, expected) -+ -+ # Test positive number that needs zero padding (high bit set) -+ test_bytes = b"\xFF" # 255, needs zero padding -+ der_encoded = der_int(test_bytes) -+ expected = b"\x02\x02\x00\xFF" # INTEGER tag + length + zero padding + value -+ self.assertEqual(der_encoded, expected) -+ -+ def test_der_len_encoding(self): -+ """Test DER length encoding helper function""" -+ # Test short form (< 128) -+ short_len = der_len(50) -+ self.assertEqual(short_len, b"\x32") # 50 in hex -+ -+ # Test long form (>= 128) -+ long_len = der_len(300) # 0x012C -+ expected = b"\x82\x01\x2C" # Long form: 0x80 | 2 bytes, then 0x012C -+ self.assertEqual(long_len, expected) -+ -+ def test_signature_format_validation_comprehensive(self): -+ """Comprehensive test of signature format validation""" -+ p521_key = ec.generate_private_key(ec.SECP521R1()).public_key() -+ -+ # Test minimum valid coordinate sizes for P-521 -+ valid_sizes = [65, 66] -+ for size in valid_sizes: -+ sig_r = b"\x01" * size -+ sig_s = b"\x02" * size -+ blob = self.create_test_signature_blob(sig_r, sig_s) -+ -+ # Should not raise exception -+ der_sig = ecdsa_der_from_tpm(blob, p521_key) -+ self.assertIsInstance(der_sig, bytes) -+ -+ # Test invalid coordinate sizes for P-521 (outside the 1-66 range) -+ invalid_sizes = [0, 67, 100] # 0 is too small, 67+ is too large -+ for size in invalid_sizes: -+ with self.subTest(size=size): -+ sig_r = b"\x01" * size if size > 0 else b"" -+ sig_s = b"\x02" * size if size > 0 else b"" -+ blob = self.create_test_signature_blob(sig_r, sig_s) -+ -+ with self.assertRaises(ValueError) as cm: -+ ecdsa_der_from_tpm(blob, p521_key) -+ self.assertIn("coordinate size", str(cm.exception)) -+ -+ - if __name__ == "__main__": - unittest.main() --- -2.47.3 - diff --git a/0022-CVE-2026-1709.patch b/0022-CVE-2026-1709.patch deleted file mode 100644 index b494256..0000000 --- a/0022-CVE-2026-1709.patch +++ /dev/null @@ -1,20 +0,0 @@ -diff --git a/keylime/web/base/server.py b/keylime/web/base/server.py -index 1d9a9c2..859b23a 100644 ---- a/keylime/web/base/server.py -+++ b/keylime/web/base/server.py -@@ -2,7 +2,6 @@ import asyncio - import multiprocessing - from abc import ABC, abstractmethod - from functools import wraps --from ssl import CERT_OPTIONAL - from typing import TYPE_CHECKING, Any, Callable, Optional - - import tornado -@@ -252,7 +251,6 @@ class Server(ABC): - self._https_port = config.getint(component, "tls_port", fallback=0) - self._max_upload_size = config.getint(component, "max_upload_size", fallback=104857600) - self._ssl_ctx = web_util.init_mtls(component) -- self._ssl_ctx.verify_mode = CERT_OPTIONAL - - def _get(self, pattern: str, controller: type["Controller"], action: str, allow_insecure: bool = False) -> None: - """Creates a new route to handle incoming GET requests issued for paths which match the given diff --git a/0023-Backport-tenant-version-negotiation-mechanism.patch b/0023-Backport-tenant-version-negotiation-mechanism.patch deleted file mode 100644 index e55ba86..0000000 --- a/0023-Backport-tenant-version-negotiation-mechanism.patch +++ /dev/null @@ -1,950 +0,0 @@ -From b5f5387cbe19df3a937359263fa7c705c47a1855 Mon Sep 17 00:00:00 2001 -From: Sergio Correia -Date: Fri, 17 Apr 2026 09:27:23 +0100 -Subject: [PATCH 23/23] Backport tenant version negotiation mechanism - -Original PRs: -- https://github.com/keylime/keylime/pull/1838 -- https://github.com/keylime/keylime/pull/1845 - -Signed-off-by: Sergio Correia ---- - keylime/api_version.py | 41 ++++- - keylime/cloud_verifier_tornado.py | 93 ++++++++-- - keylime/registrar_client.py | 63 ++++++- - keylime/tenant.py | 280 +++++++++++++++++++++++++----- - test/test_api_version.py | 65 +++++++ - 5 files changed, 470 insertions(+), 72 deletions(-) - -diff --git a/keylime/api_version.py b/keylime/api_version.py -index 1936260..de383c5 100644 ---- a/keylime/api_version.py -+++ b/keylime/api_version.py -@@ -1,6 +1,6 @@ - import re - from logging import Logger --from typing import Dict, List, Union -+from typing import Dict, List, Optional, Union - - from packaging import version - -@@ -34,6 +34,45 @@ def all_versions() -> List[str]: - return VERSIONS.copy() - - -+def negotiate_version( -+ remote_versions: Union[str, List[str]], -+ local_versions: Optional[List[str]] = None, -+ raise_on_error: bool = False, -+) -> Optional[str]: -+ """ -+ Negotiate highest API version supported by both local and remote components. -+ -+ Args: -+ remote_versions: Single version string or list from remote component -+ local_versions: Versions supported locally (default: all_versions()) -+ raise_on_error: If True, raise ValueError when no compatible version found. -+ If False (default), return None. -+ -+ Returns: -+ Highest mutually supported version, or None if incompatible and raise_on_error=False -+ -+ Raises: -+ ValueError: If no compatible version found and raise_on_error=True -+ """ -+ if local_versions is None: -+ local_versions = all_versions() -+ -+ if isinstance(remote_versions, str): -+ remote_versions = [remote_versions] -+ -+ common = set(remote_versions) & set(local_versions) -+ -+ if not common: -+ if raise_on_error: -+ raise ValueError( -+ f"No compatible API version found. " -+ f"Local supports: {local_versions}, Remote supports: {remote_versions}" -+ ) -+ return None -+ -+ return max(common, key=version.parse) -+ -+ - def is_supported_version(version_type: VersionType) -> bool: - try: - v_obj = version.parse(str(version_type)) -diff --git a/keylime/cloud_verifier_tornado.py b/keylime/cloud_verifier_tornado.py -index 67ba8af..81db22f 100644 ---- a/keylime/cloud_verifier_tornado.py -+++ b/keylime/cloud_verifier_tornado.py -@@ -518,6 +518,22 @@ class AgentsHandler(BaseHandler): - logger.warning("POST returning 400 response. Expected non zero content length.") - else: - json_body = json.loads(self.request.body) -+ -+ # Validate supported_version from tenant -+ supported_version = json_body.get("supported_version") -+ if supported_version: -+ if not keylime_api_version.is_supported_version(supported_version): -+ logger.warning( -+ "Agent %s requested API version %s which is not supported by verifier. " -+ "Verifier supports: %s. Will attempt version negotiation on first contact.", -+ agent_id, -+ supported_version, -+ keylime_api_version.all_versions(), -+ ) -+ supported_version = keylime_api_version.current_version() -+ else: -+ supported_version = keylime_api_version.current_version() -+ - agent_data = { - "v": json_body.get("v", None), - "ip": json_body["cloudagent_ip"], -@@ -531,7 +547,7 @@ class AgentsHandler(BaseHandler): - "accept_tpm_hash_algs": json_body["accept_tpm_hash_algs"], - "accept_tpm_encryption_algs": json_body["accept_tpm_encryption_algs"], - "accept_tpm_signing_algs": json_body["accept_tpm_signing_algs"], -- "supported_version": json_body["supported_version"], -+ "supported_version": supported_version, - "ak_tpm": json_body["ak_tpm"], - "mtls_cert": json_body.get("mtls_cert", None), - "hash_alg": "", -@@ -1422,7 +1438,11 @@ class MbpolicyHandler(BaseHandler): - - - async def update_agent_api_version(agent: Dict[str, Any], timeout: float = 60.0) -> Union[Dict[str, Any], None]: -+ """ -+ Query agent's /version endpoint and negotiate compatible API version. -+ """ - agent_id = agent["agent_id"] -+ old_version = agent.get("supported_version") - - logger.info("Agent %s API version bump detected, trying to update stored API version", agent_id) - kwargs = {} -@@ -1447,31 +1467,67 @@ async def update_agent_api_version(agent: Dict[str, Any], timeout: float = 60.0) - - try: - json_response = json.loads(response.body) -- new_version = json_response["results"]["supported_version"] -- old_version = agent["supported_version"] - -- # Only update the API version to use if it is supported by the verifier -- if new_version in keylime_api_version.all_versions(): -- new_version_tuple = str_to_version(new_version) -- old_version_tuple = str_to_version(old_version) -+ # Try new format first (list of versions) -+ agent_versions = json_response["results"].get("supported_versions") - -- assert new_version_tuple, f"Agent {agent_id} version {new_version} is invalid" -- assert old_version_tuple, f"Agent {agent_id} version {old_version} is invalid" -+ # Fall back to old format (single version) -+ if agent_versions is None: -+ agent_versions = json_response["results"].get("supported_version") - -- # Check that the new version is greater than current version -- if new_version_tuple <= old_version_tuple: -- logger.warning( -- "Agent %s API version %s is lower or equal to previous version %s", -+ if agent_versions: -+ # Negotiate compatible version -+ negotiated = keylime_api_version.negotiate_version(agent_versions) -+ -+ if negotiated is None: -+ logger.error( -+ "No compatible API version between verifier and agent %s. " -+ "Agent supports: %s, Verifier supports: %s", - agent_id, -- new_version, -- old_version, -+ agent_versions, -+ keylime_api_version.all_versions(), - ) - return None - -- logger.info("Agent %s new API version %s is supported", agent_id, new_version) -+ # Check if version actually changed -+ if negotiated == old_version: -+ logger.debug("Agent %s already using negotiated version %s", agent_id, negotiated) -+ return agent -+ -+ # Validate negotiated version -+ if not keylime_api_version.validate_version(negotiated): -+ logger.error("Negotiated version %s for agent %s is invalid", negotiated, agent_id) -+ return None -+ -+ # Check that the negotiated version is greater than current version (prevent downgrade) -+ negotiated_tuple = str_to_version(negotiated) -+ if not negotiated_tuple: -+ logger.error("Agent %s negotiated version %s is invalid", agent_id, negotiated) -+ return None -+ -+ # Only check for downgrade if there was a previous version and successful attestation. -+ # If attestation_count == 0, the stored version might be a fallback guess from the tenant, -+ # not a version the agent actually supported, so we allow the "downgrade". -+ attestation_count = agent.get("attestation_count", 0) -+ if old_version is not None and attestation_count > 0: -+ old_version_tuple = str_to_version(old_version) -+ if not old_version_tuple: -+ logger.error("Agent %s stored version %s is invalid", agent_id, old_version) -+ return None -+ -+ if negotiated_tuple <= old_version_tuple: -+ logger.warning( -+ "Agent %s API version %s is lower or equal to previous version %s", -+ agent_id, -+ negotiated, -+ old_version, -+ ) -+ return None -+ -+ logger.info("Agent %s new API version %s is supported", agent_id, negotiated) - - with session_context() as session: -- agent["supported_version"] = new_version -+ agent["supported_version"] = negotiated - - # Remove keys that should not go to the DB - agent_db = dict(agent) -@@ -1481,8 +1537,9 @@ async def update_agent_api_version(agent: Dict[str, Any], timeout: float = 60.0) - - session.query(VerfierMain).filter_by(agent_id=agent_id).update(agent_db) # pyright: ignore - # session.commit() is automatically called by context manager -+ - else: -- logger.warning("Agent %s new API version %s is not supported", agent_id, new_version) -+ logger.warning("Agent %s did not provide version information", agent_id) - return None - - except SQLAlchemyError as e: -diff --git a/keylime/registrar_client.py b/keylime/registrar_client.py -index 97fbc2a..e32707b 100644 ---- a/keylime/registrar_client.py -+++ b/keylime/registrar_client.py -@@ -25,7 +25,7 @@ class RegistrarData(TypedDict): - - - logger = keylime_logging.init_logging("registrar_client") --api_version = keylime_api_version.current_version() -+API_VERSION = keylime_api_version.current_version() - - MANDATORY_FIELDS = ["aik_tpm", "regcount", "ek_tpm", "ip", "port"] - -@@ -38,24 +38,61 @@ def check_mandatory_fields(results: Dict[str, Any]) -> bool: - return True - - -+def getVersions( -+ registrar_ip: str, -+ registrar_port: str, -+ tls_context: Optional[ssl.SSLContext], -+) -> Optional[Dict[str, Any]]: -+ """ -+ Fetch supported API versions from the registrar's /version endpoint. -+ -+ :returns: JSON structure containing version info, or None on failure -+ """ -+ try: -+ client = RequestsClient(f"{bracketize_ipv6(registrar_ip)}:{registrar_port}", True, tls_context=tls_context) -+ response = client.get("/version") -+ -+ if response.status_code != 200: -+ logger.warning("Failed to get versions from registrar: %s", response.status_code) -+ return None -+ -+ response_body: Dict[str, Any] = response.json() -+ if "results" not in response_body: -+ logger.warning("Unexpected response format from registrar /version endpoint") -+ return None -+ -+ results: Dict[str, Any] = response_body["results"] -+ return results -+ -+ except Exception as e: -+ logger.warning("Error fetching versions from registrar: %s", e) -+ return None -+ -+ - def getData( -- registrar_ip: str, registrar_port: str, agent_id: str, tls_context: Optional[ssl.SSLContext] -+ registrar_ip: str, -+ registrar_port: str, -+ agent_id: str, -+ tls_context: Optional[ssl.SSLContext], -+ api_version: Optional[str] = None, - ) -> Optional[RegistrarData]: - """ - Get the agent data from the registrar. - - This is called by the tenant code - -+ :param api_version: Optional API version to use. If None, uses current version. - :returns: JSON structure containing the agent data - """ - # make absolutely sure you don't ask for data that contains AIK keys unauthenticated - if not tls_context: - raise Exception("It is unsafe to use this interface to query AIKs without server-authenticated TLS.") - -+ version = api_version or API_VERSION - response = None - try: - client = RequestsClient(f"{bracketize_ipv6(registrar_ip)}:{registrar_port}", True, tls_context=tls_context) -- response = client.get(f"/v{api_version}/agents/{agent_id}") -+ response = client.get(f"/v{version}/agents/{agent_id}") - response_body = response.json() - - if response.status_code == 404: -@@ -106,18 +143,23 @@ def getData( - - - def doRegistrarDelete( -- registrar_ip: str, registrar_port: str, agent_id: str, tls_context: Optional[ssl.SSLContext] -+ registrar_ip: str, -+ registrar_port: str, -+ agent_id: str, -+ tls_context: Optional[ssl.SSLContext], -+ api_version: Optional[str] = None, - ) -> Dict[str, Any]: - """ - Delete the given agent from the registrar. - - This is called by the tenant code - -+ :param api_version: Optional API version to use. If None, uses current version. - :returns: The request response body - """ -- -+ version = api_version or API_VERSION - client = RequestsClient(f"{bracketize_ipv6(registrar_ip)}:{registrar_port}", True, tls_context=tls_context) -- response = client.delete(f"/v{api_version}/agents/{agent_id}") -+ response = client.delete(f"/v{version}/agents/{agent_id}") - response_body: Dict[str, Any] = response.json() - - if response.status_code == 200: -@@ -130,17 +172,22 @@ def doRegistrarDelete( - - - def doRegistrarList( -- registrar_ip: str, registrar_port: str, tls_context: Optional[ssl.SSLContext] -+ registrar_ip: str, -+ registrar_port: str, -+ tls_context: Optional[ssl.SSLContext], -+ api_version: Optional[str] = None, - ) -> Optional[Dict[str, Any]]: - """ - Get the list of registered agents from the registrar. - - This is called by the tenant code - -+ :param api_version: Optional API version to use. If None, uses current version. - :returns: The request response body - """ -+ version = api_version or API_VERSION - client = RequestsClient(f"{bracketize_ipv6(registrar_ip)}:{registrar_port}", True, tls_context=tls_context) -- response = client.get(f"/v{api_version}/agents/") -+ response = client.get(f"/v{version}/agents/") - response_body: Dict[str, Any] = response.json() - - if response.status_code != 200: -diff --git a/keylime/tenant.py b/keylime/tenant.py -index feb46dc..08b8a51 100644 ---- a/keylime/tenant.py -+++ b/keylime/tenant.py -@@ -54,6 +54,7 @@ class Tenant: - registrar_data: Optional[registrar_client.RegistrarData] = None - - api_version: Optional[str] = None -+ push_model: bool = False - - # uuid_service_generate_locally = None - agent_uuid: str = "" -@@ -76,7 +77,10 @@ class Tenant: - - mb_policy = None - mb_policy_name: str = "" -- supported_version: Optional[str] = None -+ supported_version: Optional[str] = None # Deprecated: use agent_api_version -+ agent_api_version: Optional[str] = None -+ verifier_api_version: Optional[str] = None -+ registrar_api_version: Optional[str] = None - - client_cert = None - client_key = None -@@ -177,6 +181,103 @@ class Tenant: - if self.registrar_ip: - self.registrar_fid_str = f"{self.registrar_fid_str} ({self.registrar_ip}:{self.registrar_port})" - -+ def _fetch_server_versions( -+ self, base_url: str, tls_context: Optional[ssl.SSLContext], server_name: str -+ ) -> Optional[List[str]]: -+ """Fetch supported versions from a server's /version endpoint.""" -+ try: -+ client = RequestsClient(base_url, True, tls_context=tls_context) -+ response = client.get("/version", timeout=self.request_timeout) -+ -+ if response.status_code == 410: -+ logger.debug("%s returned 410 (push mode), falling back to current version", server_name) -+ return None -+ -+ if response.status_code != 200: -+ logger.warning("Failed to get versions from %s: %s", server_name, response.status_code) -+ return None -+ -+ response_body: Dict[str, Any] = response.json() -+ if "results" not in response_body or "supported_versions" not in response_body["results"]: -+ logger.warning("Unexpected response format from %s /version endpoint", server_name) -+ return None -+ -+ versions: List[str] = response_body["results"]["supported_versions"] -+ return versions -+ -+ except Exception as e: -+ logger.warning("Error fetching versions from %s: %s", server_name, e) -+ return None -+ -+ def _negotiate_server_version(self, server_versions: Optional[List[str]], server_name: str) -> str: -+ """Negotiate API version with a server. -+ -+ In pull mode, API version 3.0+ is excluded since it is for push attestation only. -+ In push mode, all versions including 3.0 are available. -+ """ -+ if self.push_model: -+ local_versions = None -+ else: -+ local_versions = [v for v in keylime_api_version.all_versions() if keylime_api_version.major(v) < 3] -+ -+ if server_versions is None: -+ if self.api_version is None: -+ raise UserError("Tenant API version is not set") -+ logger.debug("Cannot negotiate version with %s, using current version %s", server_name, self.api_version) -+ return self.api_version -+ -+ try: -+ negotiated = keylime_api_version.negotiate_version(server_versions, local_versions, raise_on_error=True) -+ if negotiated is None: -+ raise UserError(f"Failed to negotiate API version with {server_name}") -+ logger.debug("Negotiated API version %s with %s", negotiated, server_name) -+ return negotiated -+ except ValueError: -+ supported = local_versions if local_versions is not None else keylime_api_version.all_versions() -+ raise UserError( -+ f"No compatible API version with {server_name}. " -+ f"Tenant supports: {supported}, " -+ f"Server supports: {server_versions}" -+ ) from None -+ -+ def negotiate_verifier_version(self) -> None: -+ """Fetch and negotiate API version with the verifier.""" -+ server_versions = self._fetch_server_versions(self.verifier_base_url, self.tls_context, "verifier") -+ self.verifier_api_version = self._negotiate_server_version(server_versions, "verifier") -+ logger.info("Using API version %s for verifier communication", self.verifier_api_version) -+ -+ def negotiate_registrar_version(self) -> None: -+ """Fetch and negotiate API version with the registrar.""" -+ if not self.registrar_ip or not self.registrar_port: -+ raise UserError("registrar_ip and registrar_port must be configured for version negotiation") -+ -+ base_url = f"{bracketize_ipv6(self.registrar_ip)}:{self.registrar_port}" -+ server_versions = self._fetch_server_versions(base_url, self.tls_context, "registrar") -+ self.registrar_api_version = self._negotiate_server_version(server_versions, "registrar") -+ logger.info("Using API version %s for registrar communication", self.registrar_api_version) -+ -+ def ensure_verifier_version(self) -> str: -+ """Ensure verifier API version is negotiated and return it. -+ -+ Performs lazy negotiation: only negotiates if not already done. -+ """ -+ if self.verifier_api_version is None: -+ self.negotiate_verifier_version() -+ if self.verifier_api_version is None: -+ raise UserError("Failed to negotiate API version with verifier") -+ return self.verifier_api_version -+ -+ def ensure_registrar_version(self) -> str: -+ """Ensure registrar API version is negotiated and return it. -+ -+ Performs lazy negotiation: only negotiates if not already done. -+ """ -+ if self.registrar_api_version is None: -+ self.negotiate_registrar_version() -+ if self.registrar_api_version is None: -+ raise UserError("Failed to negotiate API version with registrar") -+ return self.registrar_api_version -+ - def init_add(self, args: Dict[str, Any]) -> None: - """Set up required values. Command line options can overwrite these config values - -@@ -192,7 +293,11 @@ class Tenant: - if not self.registrar_ip or not self.registrar_port: - raise UserError("registrar_ip and registrar_port have both to be set in the configuration") - self.registrar_data = registrar_client.getData( -- self.registrar_ip, self.registrar_port, self.agent_uuid, self.tls_context -+ self.registrar_ip, -+ self.registrar_port, -+ self.agent_uuid, -+ self.tls_context, -+ api_version=self.ensure_registrar_version(), - ) - - if self.registrar_data is None: -@@ -218,13 +323,15 @@ class Tenant: - - self.set_full_id_str() - -- # Auto-detection for API version -- self.supported_version = args["supported_version"] -+ # Auto-detection for agent API version -+ self.agent_api_version = args.get("agent_api_version") -+ self.supported_version = self.agent_api_version - # Default to 1.0 if the agent did not send a mTLS certificate -- if self.registrar_data.get("mtls_cert", None) is None and self.supported_version is None: -- self.supported_version = "1.0" -- else: -- # Try to connect to the agent to get supported version -+ if self.registrar_data.get("mtls_cert", None) is None and self.agent_api_version is None: -+ self.agent_api_version = "1.0" -+ # Try to contact agent to get API version if in pull-mode and we have contact info -+ # Skip in push-mode since agent will send attestations to verifier directly -+ elif not self.push_model and self.agent_ip is not None and self.agent_port is not None: - if self.registrar_data["mtls_cert"] == "disabled": - self.enable_agent_mtls = False - logger.warning( -@@ -269,18 +376,66 @@ class Tenant: - if res and res.status_code == 200: - try: - data = res.json() -- api_version = data["results"]["supported_version"] -- if keylime_api_version.validate_version(api_version) and self.supported_version is None: -- self.supported_version = api_version -+ -+ # Try new format first (list of versions) -+ agent_versions = data["results"].get("supported_versions") -+ -+ # Fall back to old format (single version) for backward compatibility -+ if agent_versions is None: -+ agent_versions = data["results"].get("supported_version") -+ -+ if agent_versions: -+ negotiated = keylime_api_version.negotiate_version(agent_versions) -+ -+ if negotiated is None: -+ logger.error( -+ "No compatible API version between tenant and agent %s. " -+ "Agent supports: %s, Tenant supports: %s", -+ self.agent_uuid, -+ agent_versions, -+ keylime_api_version.all_versions(), -+ ) -+ raise UserError( -+ f"Agent {self.agent_uuid} has no compatible API version. " -+ f"Agent supports: {agent_versions}, " -+ f"Tenant supports: {keylime_api_version.all_versions()}" -+ ) -+ -+ if keylime_api_version.validate_version(negotiated) and self.agent_api_version is None: -+ self.agent_api_version = negotiated -+ logger.info( -+ "Negotiated API version %s with agent %s", -+ negotiated, -+ self.agent_uuid, -+ ) -+ elif not keylime_api_version.validate_version(negotiated): -+ logger.warning( -+ "Negotiated version %s is invalid, using current: %s", -+ negotiated, -+ keylime_api_version.current_version(), -+ ) -+ if self.agent_api_version is None: -+ self.agent_api_version = keylime_api_version.current_version() - else: -- logger.warning("API version provided by the agent is not valid") -- except (TypeError, KeyError): -- pass -+ logger.warning("Agent did not provide version information") - -- if self.supported_version is None: -- api_version = keylime_api_version.current_version() -- logger.warning("Could not detect supported API version. Defaulting to %s", api_version) -- self.supported_version = api_version -+ except (TypeError, KeyError) as e: -+ logger.warning("Failed to parse agent version response: %s", e) -+ -+ if self.agent_api_version is None: -+ fallback_version = keylime_api_version.current_version() -+ logger.warning( -+ "Could not detect supported API version. Defaulting to %s (push_model=%s, agent_ip=%s, agent_port=%s)", -+ fallback_version, -+ self.push_model, -+ self.agent_ip, -+ self.agent_port, -+ ) -+ self.agent_api_version = fallback_version -+ -+ # Keep supported_version in sync for backward compatibility -+ self.supported_version = self.agent_api_version -+ logger.info("Using API version %s for agent communication", self.agent_api_version) - - # Now set the cv_agent_ip - if "cv_agent_ip" in args and args["cv_agent_ip"] is not None: -@@ -516,7 +671,7 @@ class Tenant: - quote, - self.registrar_data["aik_tpm"], - hash_alg=hash_alg, -- compressed=(self.supported_version == "1.0"), -+ compressed=(self.agent_api_version == "1.0"), - ) - if failure: - if self.registrar_data["regcount"] > 1: -@@ -601,12 +756,14 @@ class Tenant: - "accept_tpm_signing_algs": self.accept_tpm_signing_algs, - "ak_tpm": self.registrar_data["aik_tpm"], - "mtls_cert": self.registrar_data.get("mtls_cert", None), -- "supported_version": self.supported_version, -+ "supported_version": self.agent_api_version, - } - json_message = json.dumps(data) - do_cv = RequestsClient(self.verifier_base_url, True, tls_context=self.tls_context) - response = do_cv.post( -- (f"/v{self.api_version}/agents/{self.agent_uuid}"), data=json_message, timeout=self.request_timeout -+ (f"/v{self.ensure_verifier_version()}/agents/{self.agent_uuid}"), -+ data=json_message, -+ timeout=self.request_timeout, - ) - - if response.status_code == 503: -@@ -671,7 +828,9 @@ class Tenant: - - do_cvstatus = RequestsClient(self.verifier_base_url, True, tls_context=self.tls_context) - -- response = do_cvstatus.get((f"/v{self.api_version}/agents/{self.agent_uuid}"), timeout=self.request_timeout) -+ response = do_cvstatus.get( -+ (f"/v{self.ensure_verifier_version()}/agents/{self.agent_uuid}"), timeout=self.request_timeout -+ ) - - response_json = Tenant._jsonify_response(response, print_response=False, raise_except=True) - -@@ -728,7 +887,9 @@ class Tenant: - - self.set_full_id_str() - -- response = do_cvstatus.get(f"/v{self.api_version}/agents/?verifier={verifier_id}", timeout=self.request_timeout) -+ response = do_cvstatus.get( -+ f"/v{self.ensure_verifier_version()}/agents/?verifier={verifier_id}", timeout=self.request_timeout -+ ) - - response_json = Tenant._jsonify_response(response, print_response=False, raise_except=True) - -@@ -776,7 +937,8 @@ class Tenant: - self.set_full_id_str() - - response = do_cvstatus.get( -- f"/v{self.api_version}/agents/?bulk={True}&verifier={verifier_id}", timeout=self.request_timeout -+ f"/v{self.ensure_verifier_version()}/agents/?bulk={True}&verifier={verifier_id}", -+ timeout=self.request_timeout, - ) - - response_json = Tenant._jsonify_response(response, print_response=False) -@@ -823,7 +985,9 @@ class Tenant: - self.set_full_id_str() - - do_cvdelete = RequestsClient(self.verifier_base_url, True, tls_context=self.tls_context) -- response = do_cvdelete.delete(f"/v{self.api_version}/agents/{self.agent_uuid}", timeout=self.request_timeout) -+ response = do_cvdelete.delete( -+ f"/v{self.ensure_verifier_version()}/agents/{self.agent_uuid}", timeout=self.request_timeout -+ ) - - response_json = Tenant._jsonify_response(response, print_response=False, raise_except=True) - -@@ -894,7 +1058,13 @@ class Tenant: - - self.set_full_id_str() - -- agent_info = registrar_client.getData(self.registrar_ip, self.registrar_port, self.agent_uuid, self.tls_context) -+ agent_info = registrar_client.getData( -+ self.registrar_ip, -+ self.registrar_port, -+ self.agent_uuid, -+ self.tls_context, -+ api_version=self.ensure_registrar_version(), -+ ) - - if not agent_info: - logger.info( -@@ -945,7 +1115,10 @@ class Tenant: - self.set_full_id_str() - - response = registrar_client.doRegistrarList( -- self.registrar_ip, self.registrar_port, tls_context=self.tls_context -+ self.registrar_ip, -+ self.registrar_port, -+ tls_context=self.tls_context, -+ api_version=self.ensure_registrar_version(), - ) - - # Marked for deletion (need to modify the code on CI tests) -@@ -964,7 +1137,11 @@ class Tenant: - raise UserError("registrar_ip and registrar_port have both to be set in the configuration") - - response = registrar_client.doRegistrarDelete( -- self.registrar_ip, self.registrar_port, self.agent_uuid, tls_context=self.tls_context -+ self.registrar_ip, -+ self.registrar_port, -+ self.agent_uuid, -+ tls_context=self.tls_context, -+ api_version=self.ensure_registrar_version(), - ) - - return response -@@ -1014,7 +1191,7 @@ class Tenant: - - do_cvreactivate = RequestsClient(self.verifier_base_url, True, tls_context=self.tls_context) - response = do_cvreactivate.put( -- f"/v{self.api_version}/agents/{self.agent_uuid}/reactivate", -+ f"/v{self.ensure_verifier_version()}/agents/{self.agent_uuid}/reactivate", - data=b"", - timeout=self.request_timeout, - ) -@@ -1039,7 +1216,7 @@ class Tenant: - - def do_cvstop(self) -> None: - """Stop declared active agent""" -- params = f"/v{self.api_version}/agents/{self.agent_uuid}/stop" -+ params = f"/v{self.ensure_verifier_version()}/agents/{self.agent_uuid}/stop" - do_cvstop = RequestsClient(self.verifier_base_url, True, tls_context=self.tls_context) - response = do_cvstop.put(params, data=b"", timeout=self.request_timeout) - -@@ -1073,7 +1250,7 @@ class Tenant: - # Note: We need a specific retry handler (perhaps in common), no point having localised unless we have too. - while True: - try: -- params = f"/v{self.supported_version}/quotes/identity?nonce=%s" % (self.nonce) -+ params = f"/v{self.agent_api_version}/quotes/identity?nonce=%s" % (self.nonce) - cloudagent_base_url = f"{bracketize_ipv6(self.agent_ip)}:{self.agent_port}" - - if self.enable_agent_mtls and self.registrar_data and self.registrar_data["mtls_cert"]: -@@ -1165,7 +1342,7 @@ class Tenant: - data["payload"] = self.payload.decode("utf-8") - - # post encrypted U back to CloudAgent -- params = f"/v{self.supported_version}/keys/ukey" -+ params = f"/v{self.agent_api_version}/keys/ukey" - cloudagent_base_url = f"{bracketize_ipv6(self.agent_ip)}:{self.agent_port}" - - if self.enable_agent_mtls and self.registrar_data and self.registrar_data["mtls_cert"]: -@@ -1209,14 +1386,14 @@ class Tenant: - tls_context=self.agent_tls_context, - ) as do_verify: - response = do_verify.get( -- f"/v{self.supported_version}/keys/verify?challenge={challenge}", -+ f"/v{self.agent_api_version}/keys/verify?challenge={challenge}", - timeout=self.request_timeout, - ) - else: - logger.warning("Connecting to %s without using mTLS!", self.agent_fid_str) - do_verify = RequestsClient(cloudagent_base_url, tls_enabled=False) - response = do_verify.get( -- f"/v{self.supported_version}/keys/verify?challenge={challenge}", timeout=self.request_timeout -+ f"/v{self.agent_api_version}/keys/verify?challenge={challenge}", timeout=self.request_timeout - ) - - response_json = Tenant._jsonify_response(response, print_response=False, raise_except=True) -@@ -1320,7 +1497,7 @@ class Tenant: - - cv_client = RequestsClient(self.verifier_base_url, True, tls_context=self.tls_context) - response = cv_client.post( -- f"/v{self.api_version}/allowlists/{self.runtime_policy_name}", data=body, timeout=self.request_timeout -+ f"/v{self.ensure_verifier_version()}/allowlists/{self.runtime_policy_name}", data=body, timeout=self.request_timeout - ) - response_json = Tenant._jsonify_response(response) - -@@ -1332,7 +1509,7 @@ class Tenant: - - cv_client = RequestsClient(self.verifier_base_url, True, tls_context=self.tls_context) - response = cv_client.put( -- f"/v{self.api_version}/allowlists/{self.runtime_policy_name}", data=body, timeout=self.request_timeout -+ f"/v{self.ensure_verifier_version()}/allowlists/{self.runtime_policy_name}", data=body, timeout=self.request_timeout - ) - response_json = Tenant._jsonify_response(response) - -@@ -1343,7 +1520,7 @@ class Tenant: - if not name: - raise UserError("--allowlist_name or --runtime_policy_name is required to delete a runtime policy") - cv_client = RequestsClient(self.verifier_base_url, True, tls_context=self.tls_context) -- response = cv_client.delete(f"/v{self.api_version}/allowlists/{name}", timeout=self.request_timeout) -+ response = cv_client.delete(f"/v{self.ensure_verifier_version()}/allowlists/{name}", timeout=self.request_timeout) - response_json = Tenant._jsonify_response(response) - - if response.status_code >= 400: -@@ -1353,7 +1530,7 @@ class Tenant: - if not name: - raise UserError("--allowlist_name or --runtime_policy_name is required to show a runtime policy") - cv_client = RequestsClient(self.verifier_base_url, True, tls_context=self.tls_context) -- response = cv_client.get(f"/v{self.api_version}/allowlists/{name}", timeout=self.request_timeout) -+ response = cv_client.get(f"/v{self.ensure_verifier_version()}/allowlists/{name}", timeout=self.request_timeout) - print(f"Show allowlist command response: {response.status_code}.") - response_json = Tenant._jsonify_response(response) - -@@ -1362,7 +1539,7 @@ class Tenant: - - def do_list_runtime_policy(self) -> None: - cv_client = RequestsClient(self.verifier_base_url, True, tls_context=self.tls_context) -- response = cv_client.get(f"/v{self.api_version}/allowlists/", timeout=self.request_timeout) -+ response = cv_client.get(f"/v{self.ensure_verifier_version()}/allowlists/", timeout=self.request_timeout) - print(f"list command response: {response.status_code}.") - response_json = Tenant._jsonify_response(response) - -@@ -1393,7 +1570,7 @@ class Tenant: - - cv_client = RequestsClient(self.verifier_base_url, True, tls_context=self.tls_context) - response = cv_client.post( -- f"/v{self.api_version}/mbpolicies/{self.mb_policy_name}", data=body, timeout=self.request_timeout -+ f"/v{self.ensure_verifier_version()}/mbpolicies/{self.mb_policy_name}", data=body, timeout=self.request_timeout - ) - response_json = Tenant._jsonify_response(response) - -@@ -1405,7 +1582,7 @@ class Tenant: - - cv_client = RequestsClient(self.verifier_base_url, True, tls_context=self.tls_context) - response = cv_client.put( -- f"/v{self.api_version}/mbpolicies/{self.mb_policy_name}", data=body, timeout=self.request_timeout -+ f"/v{self.ensure_verifier_version()}/mbpolicies/{self.mb_policy_name}", data=body, timeout=self.request_timeout - ) - response_json = Tenant._jsonify_response(response) - -@@ -1416,7 +1593,7 @@ class Tenant: - if not name: - raise UserError("--mb_policy_name is required to delete a runtime policy") - cv_client = RequestsClient(self.verifier_base_url, True, tls_context=self.tls_context) -- response = cv_client.delete(f"/v{self.api_version}/mbpolicies/{name}", timeout=self.request_timeout) -+ response = cv_client.delete(f"/v{self.ensure_verifier_version()}/mbpolicies/{name}", timeout=self.request_timeout) - response_json = Tenant._jsonify_response(response) - - if response.status_code >= 400: -@@ -1426,7 +1603,7 @@ class Tenant: - if not name: - raise UserError("--mb_policy_name is required to show a runtime policy") - cv_client = RequestsClient(self.verifier_base_url, True, tls_context=self.tls_context) -- response = cv_client.get(f"/v{self.api_version}/mbpolicies/{name}", timeout=self.request_timeout) -+ response = cv_client.get(f"/v{self.ensure_verifier_version()}/mbpolicies/{name}", timeout=self.request_timeout) - print(f"showmbpolicy command response: {response.status_code}.") - response_json = Tenant._jsonify_response(response) - -@@ -1435,7 +1612,7 @@ class Tenant: - - def do_list_mb_policy(self) -> None: # pylint: disable=unused-argument - cv_client = RequestsClient(self.verifier_base_url, True, tls_context=self.tls_context) -- response = cv_client.get(f"/v{self.api_version}/mbpolicies/", timeout=self.request_timeout) -+ response = cv_client.get(f"/v{self.ensure_verifier_version()}/mbpolicies/", timeout=self.request_timeout) - print(f"listmbpolicy command response: {response.status_code}.") - response_json = Tenant._jsonify_response(response) - -@@ -1688,16 +1865,29 @@ def main() -> None: - default=None, - help="The name of the measure boot policy to operate with", - ) -+ parser.add_argument( -+ "--agent-api-version", -+ default=None, -+ action="store", -+ dest="agent_api_version", -+ help="API version to use for agent communication. Detected automatically by default", -+ ) - parser.add_argument( - "--supported-version", - default=None, - action="store", - dest="supported_version", -- help="API version that is supported by the agent. Detected automatically by default", -+ help="DEPRECATED: Use --agent-api-version instead. API version to use for agent communication", - ) - - args = parser.parse_args() - -+ # Handle deprecated --supported-version argument -+ if args.supported_version is not None: -+ logger.warning("--supported-version is deprecated. Use --agent-api-version instead.") -+ if args.agent_api_version is None: -+ args.agent_api_version = args.supported_version -+ - argerr, argerrmsg = options.get_opts_error(args) - if argerr: - parser.error(argerrmsg) -diff --git a/test/test_api_version.py b/test/test_api_version.py -index 5389aaa..e1d834a 100644 ---- a/test/test_api_version.py -+++ b/test/test_api_version.py -@@ -56,6 +56,71 @@ class APIVersion_Test(unittest.TestCase): - with self.subTest(description): - self.assertEqual(api_version.is_supported_version(version), supported, description) - -+ def test_negotiate_version_with_string(self): -+ """Test negotiate_version with a single version string.""" -+ result = api_version.negotiate_version("2.0") -+ self.assertEqual(result, "2.0") -+ -+ result = api_version.negotiate_version("99.0") -+ self.assertIsNone(result) -+ -+ def test_negotiate_version_with_list(self): -+ """Test negotiate_version with a list of versions.""" -+ result = api_version.negotiate_version(["1.0", "2.0", "2.1"]) -+ self.assertEqual(result, "2.1") -+ -+ result = api_version.negotiate_version(["1.0", "2.0", "99.0"]) -+ self.assertEqual(result, "2.0") -+ -+ result = api_version.negotiate_version(["98.0", "99.0"]) -+ self.assertIsNone(result) -+ -+ def test_negotiate_version_returns_highest(self): -+ """Test that negotiate_version returns the highest common version.""" -+ result = api_version.negotiate_version(["1.0", "2.0", "2.1", "2.2", "2.3"]) -+ self.assertEqual(result, "2.3") -+ -+ result = api_version.negotiate_version(["1.0", "2.0"]) -+ self.assertEqual(result, "2.0") -+ -+ def test_negotiate_version_with_custom_local_versions(self): -+ """Test negotiate_version with custom local_versions.""" -+ local_versions = ["1.0", "2.0", "2.1", "2.2", "2.3"] -+ result = api_version.negotiate_version(["2.3", "3.0"], local_versions) -+ self.assertEqual(result, "2.3") -+ -+ result = api_version.negotiate_version(["3.0"], local_versions) -+ self.assertIsNone(result) -+ -+ def test_negotiate_version_raise_on_error(self): -+ """Test negotiate_version with raise_on_error=True.""" -+ with self.assertRaises(ValueError) as context: -+ api_version.negotiate_version(["99.0"], raise_on_error=True) -+ self.assertIn("No compatible API version", str(context.exception)) -+ -+ result = api_version.negotiate_version(["2.0"], raise_on_error=True) -+ self.assertEqual(result, "2.0") -+ -+ def test_negotiate_version_empty_list(self): -+ """Test negotiate_version with empty list.""" -+ result = api_version.negotiate_version([]) -+ self.assertIsNone(result) -+ -+ with self.assertRaises(ValueError): -+ api_version.negotiate_version([], raise_on_error=True) -+ -+ def test_negotiate_version_proper_version_comparison(self): -+ """Test that version comparison is numeric, not string-based (2.10 > 2.2).""" -+ local_versions = ["2.2", "2.10"] -+ remote_versions = ["2.2", "2.10"] -+ result = api_version.negotiate_version(remote_versions, local_versions) -+ self.assertEqual(result, "2.10", "2.10 should be greater than 2.2") -+ -+ local_versions = ["1.0", "2.1", "2.2", "2.9", "2.10", "2.11"] -+ remote_versions = ["2.2", "2.9", "2.10"] -+ result = api_version.negotiate_version(remote_versions, local_versions) -+ self.assertEqual(result, "2.10", "2.10 should be the highest common version") -+ - - if __name__ == "__main__": - unittest.main() --- -2.52.0 - diff --git a/0024-Apply-limit-on-keylime-policy-workers.patch b/0024-Apply-limit-on-keylime-policy-workers.patch deleted file mode 100644 index bbca776..0000000 --- a/0024-Apply-limit-on-keylime-policy-workers.patch +++ /dev/null @@ -1,145 +0,0 @@ -From 6466fcd8071c6332db4c695797ca6a0573e8df23 Mon Sep 17 00:00:00 2001 -From: Sergio Correia -Date: Sun, 24 May 2026 20:28:42 +0000 -Subject: [PATCH 24/24] Apply limit on keylime-policy workers - -Upstream backport: https://github.com/keylime/keylime/pull/1811 - -Signed-off-by: Sergio Correia ---- - keylime/policy/create_runtime_policy.py | 24 ++++++++++++++++++++++-- - keylime/policy/rpm_repo.py | 17 +++++++++++++---- - 2 files changed, 35 insertions(+), 6 deletions(-) - -diff --git a/keylime/policy/create_runtime_policy.py b/keylime/policy/create_runtime_policy.py -index 8e1c687..2db9b73 100644 ---- a/keylime/policy/create_runtime_policy.py -+++ b/keylime/policy/create_runtime_policy.py -@@ -434,6 +434,13 @@ def get_arg_parser(create_parser: _SubparserType, parent_parser: argparse.Argume - dest="remote_rpm_repo", - help="Remote RPM repo URL", - ) -+ repo_group.add_argument( -+ "--max-workers", -+ dest="max_workers", -+ type=_positive_int, -+ default=rpm_repo.MAX_WORKERS_DEFAULT, -+ help=f"The maximum number of workers used for RPM processing (must be a positive integer; default {rpm_repo.MAX_WORKERS_DEFAULT}).", -+ ) - - fs_group.add_argument( - "--algo", -@@ -839,6 +846,19 @@ def _get_digest_algorithm_from_map_list(maplist: Dict[str, List[str]]) -> str: - return algo - - -+def _positive_int(value: str) -> int: -+ """ -+ A custom type function for argparse that checks for a positive integer. -+ """ -+ try: -+ ivalue = int(value) -+ if ivalue <= 0: -+ raise argparse.ArgumentTypeError(f"'{value}' is not a positive integer.") -+ return ivalue -+ except ValueError as exc: -+ raise argparse.ArgumentTypeError(f"'{value}' is not a positive integer.") from exc -+ -+ - def create_runtime_policy(args: argparse.Namespace) -> Optional[RuntimePolicyType]: - """Create a runtime policy from the input arguments.""" - policy = None -@@ -924,7 +944,7 @@ def create_runtime_policy(args: argparse.Namespace) -> Optional[RuntimePolicyTyp - # FIXME: pass the IMA sigs as well. - local_rpm_digests = {} - local_rpm_digests, _imasigs, ok = rpm_repo.analyze_local_repo( -- args.local_rpm_repo, digests=local_rpm_digests -+ args.local_rpm_repo, digests=local_rpm_digests, max_workers=args.max_workers - ) - if not ok: - return None -@@ -932,7 +952,7 @@ def create_runtime_policy(args: argparse.Namespace) -> Optional[RuntimePolicyTyp - # FIXME: pass the IMA sigs as well. - remote_rpm_digests = {} - remote_rpm_digests, _imasigs, ok = rpm_repo.analyze_remote_repo( -- args.remote_rpm_repo, digests=remote_rpm_digests -+ args.remote_rpm_repo, digests=remote_rpm_digests, max_workers=args.max_workers - ) - if not ok: - return None -diff --git a/keylime/policy/rpm_repo.py b/keylime/policy/rpm_repo.py -index d6a8bda..ab55484 100644 ---- a/keylime/policy/rpm_repo.py -+++ b/keylime/policy/rpm_repo.py -@@ -25,6 +25,8 @@ from keylime.types import PathLike_str - - logger = Logger().logger() - -+MAX_WORKERS_DEFAULT = 8 -+ - - def _parse_rpm_header(hdr: rpm.hdr) -> Tuple[Dict[str, List[str]], Dict[str, List[bytes]]]: - # First, the file digests. -@@ -110,6 +112,7 @@ def analyze_local_repo( - digests: Optional[Dict[str, List[str]]] = None, - imasigs: Optional[Dict[str, List[bytes]]] = None, - jobs: Optional[int] = None, -+ max_workers: int = MAX_WORKERS_DEFAULT, - ) -> Tuple[Dict[str, List[str]], Dict[str, List[bytes]], bool]: - """ - Analyze a local repository. -@@ -121,6 +124,7 @@ def analyze_local_repo( - :param imasigs: dict of str and a list of bytes, to store the files and - their associated IMA signatures - :param jobs: integer, the number of jobs to use when processing the rpms -+ :param max_workers: integer, the maximum number of workers used for RPM processing - :return: tuple with the dict of digests, the dict of IMA signatures and a - boolean indicating the success of this method - """ -@@ -163,7 +167,7 @@ def analyze_local_repo( - else: - logger.warning("Warning. Unsigned repository. Continuing the RPM scanning") - -- jobs = jobs if jobs else multiprocessing.cpu_count() -+ jobs = jobs if jobs else min(multiprocessing.cpu_count(), max_workers) - - if not digests: - digests = {} -@@ -255,7 +259,11 @@ def _parse_xml_file(filepath: str) -> ET.ElementTree: - - - def _analyze_remote_repo( -- repo: str, digests: Optional[Dict[str, List[str]]], imasigs: Optional[Dict[str, List[bytes]]], jobs: Optional[int] -+ repo: str, -+ digests: Optional[Dict[str, List[str]]], -+ imasigs: Optional[Dict[str, List[bytes]]], -+ jobs: Optional[int], -+ max_workers: int = MAX_WORKERS_DEFAULT, - ) -> Tuple[Dict[str, List[str]], Dict[str, List[bytes]], bool]: - # Make the repo ends with "/", so we can be considered as a base URL - repo = repo if (repo).endswith("/") else f"{repo}/" -@@ -319,7 +327,7 @@ def _analyze_remote_repo( - # single thread (asyncio) or multiple process. To avoid change - # all the stack, I go for synchronous functions but with many - # process. In the future we can move all to asyncio. -- jobs = jobs if jobs else (multiprocessing.cpu_count() * 8) -+ jobs = jobs if jobs else max_workers - - # Analyze all the RPMs in parallel - with multiprocessing.Pool(jobs) as pool: -@@ -335,10 +343,11 @@ def analyze_remote_repo( - digests: Optional[Dict[str, List[str]]] = None, - imasigs: Optional[Dict[str, List[bytes]]] = None, - jobs: Optional[int] = None, -+ max_workers: int = MAX_WORKERS_DEFAULT, - ) -> Tuple[Dict[str, List[str]], Dict[str, List[bytes]], bool]: - """Analyze a remote repository.""" - try: -- return _analyze_remote_repo(str(*repourl), digests, imasigs, jobs) -+ return _analyze_remote_repo(str(*repourl), digests, imasigs, jobs, max_workers) - except Exception as exc: - logger.error(exc) - return {}, {}, False --- -2.47.3 - diff --git a/keylime.spec b/keylime.spec index c9f20c2..831c4bd 100644 --- a/keylime.spec +++ b/keylime.spec @@ -1,5 +1,5 @@ %global srcname keylime -%global policy_version 42.1.2 +%global policy_version 44.1.0 %global with_selinux 1 %global selinuxtype targeted @@ -8,8 +8,8 @@ %global debug_package %{nil} Name: keylime -Version: 7.12.1 -Release: 17%{?dist} +Version: 7.14.3 +Release: 1%{?dist} Summary: Open source TPM software for Bootstrapping and Maintaining Trust URL: https://github.com/keylime/keylime @@ -18,68 +18,28 @@ Source1: https://github.com/RedHat-SP-Security/%{name}-selinux/archive/v% Source2: %{srcname}.sysusers Source3: %{srcname}.tmpfiles +# Update keylime to work with python 3.9. Patch: 0001-Make-keylime-compatible-with-python-3.9.patch -Patch: 0002-tests-fix-rpm-repo-tests-from-create-runtime-policy.patch -Patch: 0003-tests-skip-measured-boot-related-tests-for-s390x-and.patch -Patch: 0004-templates-duplicate-str_to_version-in-the-adjust-scr.patch + # RHEL-9 ships a slightly modified version of create_allowlist.sh and # also a "default" server_key_password for the registrar and verifier. -# DO NOT REMOVE THE FOLLOWING TWO PATCHES IN FOLLOWING RHEL-9.x REBASES. -Patch: 0005-Restore-RHEL-9-version-of-create_allowlist.sh.patch -Patch: 0006-Revert-default-server_key_password-for-verifier-regi.patch -# Backported from https://github.com/keylime/keylime/pull/1782 -Patch: 0007-fix_db_connection_leaks.patch +# We also disable push model in RHEL-9. +# DO NOT REMOVE THE FOLLOWING THREE PATCHES IN FOLLOWING RHEL-9.x REBASES. +Patch: 0002-Restore-RHEL-9-version-of-create_allowlist.sh.patch +Patch: 0003-Revert-default-server_key_password-for-verifier-regi.patch +Patch: 0004-Disable-push-model-agent-driven-attestation-for-RHEL.patch -# Backported from https://github.com/keylime/keylime/pull/1791 -Patch: 0008-mb-support-EV_EFI_HANDOFF_TABLES-events-on-PCR1.patch -Patch: 0009-mb-support-vendor_db-as-logged-by-newer-shim-version.patch - -# Backported from https://github.com/keylime/keylime/pull/1784 -# and https://github.com/keylime/keylime/pull/1785. -Patch: 0010-verifier-Gracefully-shutdown-on-signal.patch -Patch: 0011-revocations-Try-to-send-notifications-on-shutdown.patch -Patch: 0012-requests_client-close-the-session-at-the-end-of-the-.patch -Patch: 0013-fix-malformed-certs-workaround.patch - -# CVE-2025-13609 -# Backports from: -# - https://github.com/keylime/keylime/pull/1817/commits/1024e19d -# - https://github.com/keylime/keylime/pull/1825 -Patch: 0014-Add-shared-memory-infrastructure-for-multiprocess-co.patch -Patch: 0015-Fix-registrar-duplicate-UUID-vulnerability.patch - -# Backported from: -# - https://github.com/keylime/keylime/pull/1746 -# - https://github.com/keylime/keylime/pull/1803 -# - https://github.com/keylime/keylime/pull/1808 -# ECC attestation support. -Patch: 0016-algorithms-add-support-for-specific-ECC-curve-algori.patch -Patch: 0017-algorithms-add-support-for-specific-RSA-algorithms.patch -Patch: 0018-tpm_util-fix-quote-signature-extraction-for-ECDSA.patch -Patch: 0019-tpm-fix-ECC-P-521-coordinate-validation.patch -Patch: 0020-tpm-fix-ECC-P-521-credential-activation-with-consist.patch -Patch: 0021-tpm-fix-ECC-signature-parsing-to-support-variable-le.patch - -# CVE-2026-1709 -# Fix registrar authentication bypass -Patch: 0022-CVE-2026-1709.patch - -# Tenant version negotiation. -# Backport from: -# - https://github.com/keylime/keylime/pull/1838 -# - https://github.com/keylime/keylime/pull/1845 -Patch: 0023-Backport-tenant-version-negotiation-mechanism.patch - -# Backport from: -# - https://github.com/keylime/keylime/pull/1811 -Patch: 0024-Apply-limit-on-keylime-policy-workers.patch +# Backport: https://github.com/keylime/keylime/pull/1930 +Patch: 0005-fix-handle-invalid-config-values-for-typed-server-op.patch License: ASL 2.0 and MIT BuildRequires: git-core +BuildRequires: openssl BuildRequires: openssl-devel BuildRequires: python3-devel BuildRequires: python3-dbus +BuildRequires: python3-gpg BuildRequires: python3-jinja2 BuildRequires: python3-cryptography BuildRequires: python3-pyasn1 @@ -89,6 +49,7 @@ BuildRequires: python3-sqlalchemy BuildRequires: python3-lark-parser BuildRequires: python3-psutil BuildRequires: python3-pyyaml +BuildRequires: python3-requests BuildRequires: python3-jsonschema BuildRequires: python3-setuptools BuildRequires: systemd-rpm-macros @@ -468,6 +429,10 @@ fi %license LICENSE %changelog +* Mon Jun 01 2026 Sergio Correia - 7.14.1-1 +- Update to 7.14.2 + Resolves: RHEL-180618 + * Fri May 22 2026 Sergio Correia 7.12.1-17 - Apply limit on keylime-policy workers Resolves: RHEL-114482 diff --git a/sources b/sources index 9b71be8..be0a79b 100644 --- a/sources +++ b/sources @@ -1,2 +1,2 @@ -SHA512 (v7.12.1.tar.gz) = c1297ebfc659102d73283255cfda4a977dfbff9bdd3748e05de405dadb70f752ad39aa5848edda9143d8ec620d07c21f1551fa4a914c99397620ab1682e58458 -SHA512 (keylime-selinux-42.1.2.tar.gz) = cb7b7b10d1d81af628a7ffdadc1be5af6d75851a44f58cff04edc575cbba1613447e56bfa1fb86660ec7c15e5fcf16ba51f2984094550ba3e08f8095b800b741 +SHA512 (v7.14.3.tar.gz) = 254c8b100bd2cc5b793d1266451e1f6ad8ac05ace2c8cc0c62a78a6faba487b45d1050857b39ffed8b6c8bb41bfa6d0d9e56f840d50ff56876eb54006cc9bc67 +SHA512 (keylime-selinux-44.1.0.tar.gz) = 3ba9869cc07c3bd64ef29f572a13a3c3ea92eec50f5cfb3b882835e6db6b835cb1a4c272ab92273da6912bed36abe54eafdac7c8e5b103dd3f761b1d38e7edc6