Skip to content

Commit

Permalink
Add Certificate and Dictionary SQLAlchemy data types
Browse files Browse the repository at this point in the history
Signed-off-by: Jean Snyman <[email protected]>
  • Loading branch information
stringlytyped committed Mar 20, 2024
1 parent 1c2db6b commit 705d9d4
Show file tree
Hide file tree
Showing 9 changed files with 398 additions and 64 deletions.
2 changes: 2 additions & 0 deletions keylime/models/base/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@
from keylime.models.base.da import da_manager
from keylime.models.base.db import db_manager
from keylime.models.base.persistable_model import PersistableModel
from keylime.models.base.types.certificate import Certificate
from keylime.models.base.types.dictionary import Dictionary
30 changes: 21 additions & 9 deletions keylime/models/base/basic_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from abc import ABC, abstractmethod
from types import MappingProxyType

from sqlalchemy.dialects.sqlite import dialect as sqlite_dialect
from sqlalchemy.types import PickleType

from keylime.models.base.errors import FieldValueInvalid, UndefinedField
Expand Down Expand Up @@ -208,18 +209,29 @@ def change(self, name, value):
if name not in self.__class__.fields:
raise UndefinedField(f"field '{name}' does not exist in model '{self.__class__.__name__}'")

self._changes[name] = value
# Reset the errors for the field to an empty list
self._errors[name] = list()

# Get Field instance for name in order to obtain its type (TypeEngine object)
field = self.__class__.fields[name]
# Get processor which translates values of the given type to a format which can be stored in a DB
bind_processor = field.type.bind_processor(sqlite_dialect())
# Get processor which translates values retrieved by a DB query according to the field type
result_processor = field.type.result_processor(sqlite_dialect(), None)

if isinstance(field.type, PickleType):
try:
field.type.pickler.dumps(value)
except:
self._add_error(name, "is of an incorrect type")
elif getattr(type, "python_type", None):
if not isinstance(value, field.type.python_type):
try:
# Process incoming value as if it were to be stored in a DB (if type requires inbound processing)
value = bind_processor(value) if bind_processor else value
# Process resulting value as if it were being retrieved from a DB (if type requires outbound processing)
value = result_processor(value) if result_processor else value
# Add value (processed according to the field type) to the model instance's collection of changes
self._changes[name] = value
except:
# If the above mock DB storage and retrieval fails, the incoming value is of an incorrect type for the field
if hasattr(field.type, "type_mismatch_msg") and not callable(getattr(field.type, "type_mismatch_msg")):
# Some custom types provide a special "invalid type" message
self._add_error(name, field.type.type_mismatch_msg)
else:
self._add_error(name, "is of an incorrect type")

def cast_changes(self, changes, permitted={}):
Expand Down Expand Up @@ -273,7 +285,7 @@ def validate_base64(self, fields, msg="must be Base64 encoded"):
value = self.values.get(field) or ""

try:
base64.b64decode(value)
base64.b64decode(value, validate=True)
except binascii.Error:
self._add_error(field, msg)

Expand Down
21 changes: 7 additions & 14 deletions keylime/models/base/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from sqlalchemy.orm import Session, registry, scoped_session, sessionmaker

from keylime import config, keylime_logging
from keylime.models.base.errors import BackendMissing

logger = keylime_logging.init_logging("keylime_db")

Expand Down Expand Up @@ -92,22 +93,22 @@ def make_engine(self, service: str) -> Engine:

@property
def service(self) -> Optional[str]:
if not self._registry:
raise NoEngineError("cannot access the service for a DBManager before a call to db_manager.make_engine()")
if not self._service:
raise BackendMissing("cannot access the service for a DBManager before a call to db_manager.make_engine()")

return self._service

@property
def engine(self) -> Engine:
if not self._registry:
raise NoEngineError("cannot access the engine for a DBManager before a call to db_manager.make_engine()")
if not self._engine:
raise BackendMissing("cannot access the engine for a DBManager before a call to db_manager.make_engine()")

return self._engine

@property
def registry(self) -> registry:
if not self._registry:
raise NoEngineError("cannot access the registry for a DBManager before a call to db_manager.make_engine()")
raise BackendMissing("cannot access the registry for a DBManager before a call to db_manager.make_engine()")

return self._registry

Expand All @@ -116,7 +117,7 @@ def session(self) -> Session:
To use: session = self.session()
"""
if not self._registry:
raise NoEngineError("cannot access the session for a DBManager before a call to db_manager.make_engine()")
raise BackendMissing("cannot access the session for a DBManager before a call to db_manager.make_engine()")

if not self._scoped_session:
self._scoped_session = scoped_session(sessionmaker())
Expand All @@ -141,13 +142,5 @@ def session_context(self):
raise


class DBManagerError(Exception):
pass


class NoEngineError(DBManagerError):
pass


# Create a global DBManager which can be referenced from any module
db_manager = DBManager()
8 changes: 8 additions & 0 deletions keylime/models/base/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,11 @@ class FieldNonNullable(FieldValueInvalid):

class FieldTypeMismatch(FieldValueInvalid):
pass


class StorageManagerError(Exception):
pass


class BackendMissing(StorageManagerError):
pass
6 changes: 0 additions & 6 deletions keylime/models/base/field.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,6 @@ def __init__(self, name, type, nullable=False):
f"inheriting from 'sqlalchemy.types.TypeEngine'"
)

if not self.python_type and not isinstance(type, PickleType):
raise FieldDefinitionInvalid(
f"field '{name}' cannot be defined with type '{type.__class__.__name__}' as this is not a SQLAlchemy "
f"datatype with a defined '{type.__class__.__name__}.python_type' nor a SQLAlchemy 'PickleType'"
)

def __get__(self, obj, objtype=None):
if obj is None:
return self
Expand Down
2 changes: 2 additions & 0 deletions keylime/models/base/types/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from keylime.models.base.types.certificate import Certificate
from keylime.models.base.types.dictionary import Dictionary
185 changes: 185 additions & 0 deletions keylime/models/base/types/certificate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import base64
import binascii

import cryptography.x509
from cryptography.hazmat.primitives.serialization import Encoding
from pyasn1.error import PyAsn1Error, SubstrateUnderrunError
from sqlalchemy.types import Text, TypeDecorator

from keylime import cert_utils


class Certificate(TypeDecorator):
"""The Certificate class implements the SQLAlchemy type API (by inheriting from ``TypeDecorator`` and, in turn,
``TypeEngine``) to allow model fields to be declared as containing objects of type
``cryptography.x509.Certificate``. When such a field is set, the incoming value is decoded as appropriate and cast
to an ``cryptography.x509.Certificate`` object. If saved to a database, the object is converted to its DER
representation and encoded as a string using Base64.
The schema of the backing database table is thus assumed to declare the certificate-containing column as type
``"Text"`` or comparable, in line with established Keylime convention. This is somewhat inefficient, so we may wish
to consider switching to ``"Blob"`` at some point such that certificates are saved to the database as byte strings
instead.
Example 1
---------
To use the Certificate type, declare a model field as in the following example::
class SomeModel(PersistableModel):
def _schema(self):
cls._field("cert", Certificate, nullable=True)
# (Any additional schema declarations...)
Then, you can set the field by providing:
* a previously-instantiated ``cryptography.x509.Certificate`` object;
* a ``bytes`` object containing DER-encoded binary certificate data; or
* a ``str`` object containing DER binary certificate data which has been Base64 encoded; or
* a ``str`` object containing PEM-encoded certificate data.
This is shown in the code sample below::
record = SomeModel.empty()
# Set cert field using ``certificate`` which is of type ``cryptography.x509.Certificate``:
record.cert = certificate
# Set cert field using DER binary data:
record.cert = b'0\x82\x04...'
# Set cert field using Base64-encoded data:
record.cert = "MIIE..."
# Set cert field using PEM-encoded data:
record.cert = "-----BEGIN CERTIFICATE-----\nMIIE..."
On performing ``record.commit_changes()``, the certificate will be saved to the database using the Base64
representation (without the PEM header and footer), i.e., ``"MIIE..."``.
Example 2
---------
You may also use the Certificate type's casting functionality outside a model by using the ``cast`` static method::
# If ``certificate`` is of type ``cryptography.x509.Certificate`, casting it returns it unchanged:
cert = Certificate.cast(certificate)
# Converts DER binary certificate data to ``cryptography.x509.Certificate`:
cert = Certificate.cast(b'0\x82\x04...')
# Converts Base64-encoded certificate data to ``cryptography.x509.Certificate`:
cert = Certificate.cast("MIIE...")
# Converts PEM-encoded certificate data to ``cryptography.x509.Certificate`:
cert = Certificate.cast("-----BEGIN CERTIFICATE-----\nMIIE...")
"""

impl = Text
cache_ok = True

@staticmethod
def cast(value):
"""Tries to interpret the given value as an X.509 certificate and convert it to an
`cryptography.x509.Certificate` 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 not of type ``str``, ``bytes`` or ``cryptography.x509.Certificate``
:raises: :class:`ValueError`: ``value`` does not contain data which is interpretable as a certificate
:returns: A ``cryptography.x509.Certificate`` object
"""

if isinstance(value, cryptography.x509.Certificate):
return value

elif isinstance(value, bytes):
try:
return cert_utils.x509_der_cert(value)
except (binascii.Error, PyAsn1Error, SubstrateUnderrunError):
raise ValueError(
f"value cast to certificate appears DER encoded but cannot be deserialized as such: '{value}'"
)

elif isinstance(value, str) and value.startswith("-----BEGIN CERTIFICATE-----"):
try:
return cert_utils.x509_pem_cert(value)
except (PyAsn1Error, SubstrateUnderrunError):
raise ValueError(
f"value cast to certificate appears PEM encoded but cannot be deserialized as such: '{value}'"
)

elif isinstance(value, str):
try:
return cert_utils.x509_der_cert(base64.b64decode(value, validate=True))
except (binascii.Error, PyAsn1Error, SubstrateUnderrunError):
raise ValueError(
f"value cast to certificate appears Base64 encoded but cannot be deserialized as such: '{value}'"
)

else:
raise TypeError(
f"value cast to certificate is of type '{value.__class__.__name__}' but should be one of 'str', "
f"'bytes' or 'cryptography.x509.Certificate': '{value}'"
)

def process_bind_param(self, value, dialect):
"""Prepares incoming certificate data for storage in a database. SQLAlchemy's ``TypeDecorator`` class uses this
to construct the callables which are returned when ``self.bind_processor(dialect)`` or
``self.literal_processor(dialect)`` are called. These callables in turn are used to prepare certificates
for inclusion within a SQL statement.
When the Certificate type is used in a model which is not database persisted, the callable returned by
``self.bind_processor(dialect)`` is still used to ensure that the data saved in the record is of the
expected type and format.
:param value: The value to prepare for database storage (may be in DER, Base64(DER), or PEM format)
:raises: :class:`TypeError`: ``value`` is not of type ``str``, ``bytes`` or ``cryptography.x509.Certificate``
:raises: :class:`ValueError`: ``value`` does not contain data which is interpretable as a certificate
:returns: A string containing the Base64-encoded certificate
"""

if not value:
return None

# Cast incoming value to Certificate object
cert = Certificate.cast(value)
# Save in DB 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 process_result_value(self, value, dialect):
"""Prepares outgoing certificate data fetched from a database. SQLAlchemy's ``TypeDecorator`` class uses this
to construct the callable which is returned by ``self.result_processor(dialect)``. This callable in turn is
used to instantiate a ``cryptography.x509.Certificate`` object from certificate data returned by a SQL query.
When the Certificate type is used in a model which is not database persisted, the callable returned by
``self.result_processor(dialect)`` is still called to ensure that the data saved in the record is of the
expected type and format.
:param value: The outgoing value retrieved from the database
:raises: :class:`TypeError`: ``value`` is not of type ``str``, ``bytes`` or ``cryptography.x509.Certificate``
:raises: :class:`ValueError`: ``value`` does not contain data which is interpretable as a certificate
:returns: A ``cryptography.x509.Certificate`` object
"""

if not value:
return None

# Cast outgoing value from DB to Certificate object
return Certificate.cast(value)

@property
def type_mismatch_msg(self):
"""A read-only property used as the error message when a model field of type Certificate is set to a value
which is not interpretable as an X.509 certificate. When operating in push mode, this message is returned in
the HTTP response to signify that an invalid API request was made and provide guidance on how to correct it.
:returns: A string containing the error message
"""

return "must be a valid binary X.509 certificate encoded using Base64"
Loading

0 comments on commit 705d9d4

Please sign in to comment.