Skip to content

Commit 901bf18

Browse files
committed
Introduce new REST API framework and refactor registrar implementation
This commit consolidates the work completed as part of pull request keylime#1523 and contributes a new way of architecting REST APIs based loosely on the model-view-controller (MVC) pattern. The class library in `keylime.web.base` and `keylime.models.base` provides a number of building blocks for structuring web service code in a consistent and ordered fashion. These include: - Routes: parameterised URI patterns for declaring API endpoints - Servers: direct requests to a controller based on a list of routes - Controllers: handle requests and produce an appropriate response - Models: data structures which can be mutated, validated and persisted according to a set pattern Additionally, this commit re-implements the registrar APIs in the new paradigm. `keylime.registrar_common` is no longer invoked and is effectively replaced by `keylime.web.registrar_server` and `keylime.web.registrar.agents_controller`. The `registrarmain` database table is now represented in memory using the `RegistrarAgent` model. The model defines a schema for agent records and encapsulates the functionality for mutating these records instead of overloading request handlers with this responsibility. Certificate and key verification checks are broken into several small methods each with a clear, minimal purpose in an effort to ensure readability, traceability and composability (even when the registrar is extended in the future). The refactor of the registrar therefore acts as a good demonstration of how the new web framework facilitates writing modular code with clear separation of concerns. Future contributions to implement agent-driven attestation (keylime/enhancements#103) will be done in a similar way. Some minor features have been added or changed, e.g., request logging is now more detailed and log messages try to be more helpful where possible. The user now has the option of suppressing a portion of the warnings generated when a certificate is not strictly ASN.1 DER compliant, or even rejecting such certificates when received from an agent. This partially fixes issue keylime#1559 (which will be further addressed in subsequent PRs). Other than that, this commit should be functionally equivalent to earlier Keylime versions. Squashes commits: 3b8119c, 796417d, a0d3cf7, 1b42ee2, c52b005, f5869aa, 3cd4c2a, 75facbd, e6ec507, 45a1362, 3c8f202, 30eb7dc, 2b9de05, b4a2df1, 1c2db6b, 705d9d4, e28baf6, 282071c, 2f7095d, f254a78, a249d28, 9fe4042, b3eaa3e, ca3782a, 0ae1249, 9696a39, 33b7184, 7d8a4ee, 55eacb9, 2496836, 0d8a232, 4690017, ce9315a, 9c79359, 5055dc1, 9387a0b, 0db1fb8, 42fa62d, 89474ee, 7527175, fc1217e, de282c4, a28078f. For context, refer to PR keylime#1523. Signed-off-by: Jean Snyman <[email protected]>
1 parent e707465 commit 901bf18

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+6109
-21
lines changed

.github/workflows/test.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ jobs:
4242
- name: Install Python dependencies
4343
run: sudo dnf -y install python3.6 tox python3-pip
4444
- name: Run lints
45-
run: tox -vv -e 'pylint,pylint36'
45+
run: tox -vv -e 'pylint'
4646
- name: Run mypy
4747
run: tox -vv -e 'mypy'
4848
- name: Run pyright

.packit.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,5 @@ jobs:
44
metadata:
55
targets:
66
- fedora-all
7-
- centos-stream-9-x86_64
7+
- centos-stream-10-x86_64
88
skip_build: true

.pre-commit-config.yaml

+2-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ repos:
66
- id: isort
77
name: isort (python)
88
- repo: https://github.com/psf/black
9-
rev: 23.1.0
9+
rev: 23.10.0
1010
hooks:
1111
- id: black
12+
args: ["--target-version", "py310"]

keylime.conf

+10-1
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,15 @@ signed_attributes = ek_tpm,aik_tpm,ekcert
674674
# "iak_idevid": only allow agents with an IAK and IDevID to register
675675
tpm_identity = default
676676

677+
# The below option controls what Keylime does when it encounters a certificate which is not parse-able when strict
678+
# ASN.1 Distinguished Encoding Rules (DER) are enforced. The default behaviour ("warn") is to log a warning but still
679+
# accept the certificate, so long as it can be interpreted by a fallback parser.
680+
# The following values are accepted:
681+
# "warn": log a warning and re-encode the certificate with the more-forgiving fallback parser (the default)
682+
# "reject": log an error and refuse to accept the certificate
683+
# "ignore": silently re-encode the certificate without logging a message
684+
malformed_cert_action = warn
685+
677686

678687
#=============================================================================
679688
[ca]
@@ -723,7 +732,7 @@ keys = consoleHandler
723732
keys = formatter
724733

725734
[formatter_formatter]
726-
format = %(asctime)s.%(msecs)03d - %(name)s - %(levelname)s - %(message)s
735+
format = %(asctime)s.%(msecs)03d - %(name)s - %(levelname)s - %(message)s %(reqidf)s
727736
datefmt = %Y-%m-%d %H:%M:%S
728737

729738
[logger_root]

keylime/cmd/registrar.py

+40-6
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,55 @@
1-
from keylime import config, keylime_logging, registrar_common
1+
import sys
2+
3+
import cryptography
4+
5+
from keylime import config, keylime_logging
26
from keylime.common.migrations import apply
7+
from keylime.models import da_manager, db_manager
8+
from keylime.web import RegistrarServer
39

410
logger = keylime_logging.init_logging("registrar")
511

612

13+
def _check_devid_requirements() -> None:
14+
"""Checks that the cryptography package is the version needed for DevID support (>= 38). Exits if this requirement
15+
is not met and DevID is the only identity allowable by the config.
16+
"""
17+
tpm_identity = config.get("registrar", "tpm_identity", fallback="default")
18+
19+
if int(cryptography.__version__.split(".", maxsplit=1)[0]) < 38:
20+
if tpm_identity == "iak_idevid":
21+
logger.error(
22+
"DevID is REQUIRED in config ('tpm_identity = %s') but python-cryptography version < 38", tpm_identity
23+
)
24+
sys.exit(1)
25+
26+
if tpm_identity in ("default", "ek_cert_or_iak_idevid"):
27+
logger.info(
28+
"DevID is enabled in config ('tpm_identity = %s') but python-cryptography version < 38, so only the EK "
29+
"will be used for device registration",
30+
tpm_identity,
31+
)
32+
33+
734
def main() -> None:
35+
logger.info("Starting Keylime registrar...")
36+
837
config.check_version("registrar", logger=logger)
938

1039
# if we are configured to auto-migrate the DB, check if there are any migrations to perform
1140
if config.has_option("registrar", "auto_migrate_db") and config.getboolean("registrar", "auto_migrate_db"):
1241
apply("registrar")
1342

14-
registrar_common.start(
15-
config.get("registrar", "ip"),
16-
config.getint("registrar", "tls_port"),
17-
config.getint("registrar", "port"),
18-
)
43+
# Check if DevID is required in config and, if so, that the required dependencies are met
44+
_check_devid_requirements()
45+
# Prepare to use the registrar database
46+
db_manager.make_engine("registrar")
47+
# Prepare backend for durable attestation, if configured
48+
da_manager.make_backend("registrar")
49+
50+
# Start HTTP server
51+
server = RegistrarServer()
52+
server.start_multi()
1953

2054

2155
if __name__ == "__main__":

keylime/common/migrations.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@ def apply(db_name: Optional[str]) -> None:
1919

2020
alembic_args.extend(["upgrade", "head"])
2121

22-
alembic.config.main(argv=alembic_args)
22+
alembic.config.main(argv=alembic_args) # type: ignore[no-untyped-call]

keylime/da/record.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,9 @@ def __init__(self, service: str, key_tls_pub: Optional[str] = "") -> None:
4646
def record_create(
4747
self,
4848
agent_data: Dict[Any, Any],
49-
attestation_data: Dict[Any, Any],
49+
attestation_data: Optional[Dict[Any, Any]],
5050
mb_policy_data: Optional[str],
51-
runtime_policy_data: Dict[Any, Any],
51+
runtime_policy_data: Optional[Dict[Any, Any]],
5252
service: str = "auto",
5353
signed_attributes: str = "auto",
5454
) -> None:

keylime/keylime_logging.py

+33-1
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,23 @@
1+
import contextvars
12
import logging
23
from logging import Logger
34
from logging import config as logging_config
4-
from typing import Any, Callable, Dict
5+
from typing import TYPE_CHECKING, Any, Callable, Dict
56

67
from keylime import config
78

9+
if TYPE_CHECKING:
10+
from logging import LogRecord
11+
812
try:
913
logging_config.fileConfig(config.get_config("logging"))
1014
except KeyError:
1115
logging.basicConfig(format="%(asctime)s %(name)-12s %(levelname)-8s %(message)s", level=logging.DEBUG)
1216

1317

18+
request_id_var: contextvars.ContextVar[str] = contextvars.ContextVar("request_id")
19+
20+
1421
def set_log_func(loglevel: int, logger: Logger) -> Callable[..., None]:
1522
log_func = logger.info
1623

@@ -45,8 +52,33 @@ def log_http_response(logger: Logger, loglevel: int, response_body: Dict[str, An
4552
return True
4653

4754

55+
def annotate_logger(logger: Logger) -> None:
56+
request_id_filter = RequestIDFilter()
57+
58+
for handler in logger.handlers:
59+
handler.addFilter(request_id_filter)
60+
61+
4862
def init_logging(loggername: str) -> Logger:
4963
logger = logging.getLogger(f"keylime.{loggername}")
5064
logging.getLogger("requests").setLevel(logging.WARNING)
5165

66+
# Disable default Tornado logs, as we are outputting more detail to the 'keylime.web' logger
67+
logging.getLogger("tornado.general").disabled = True
68+
logging.getLogger("tornado.access").disabled = True
69+
logging.getLogger("tornado.application").disabled = True
70+
71+
# Add metadata to root logger, so that it is inherited by all
72+
annotate_logger(logging.getLogger())
73+
5274
return logger
75+
76+
77+
class RequestIDFilter(logging.Filter):
78+
def filter(self, record: "LogRecord") -> bool:
79+
reqid = request_id_var.get("")
80+
81+
record.reqid = reqid
82+
record.reqidf = f"(reqid={reqid})" if reqid else ""
83+
84+
return True
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""Convert registrar column types
2+
3+
Revision ID: 330024be7bef
4+
Revises: 9d2f6fab52b1
5+
Create Date: 2024-02-15 11:48:41.458971
6+
7+
"""
8+
import sqlalchemy as sa
9+
from alembic import op
10+
11+
# revision identifiers, used by Alembic.
12+
revision = "330024be7bef"
13+
down_revision = "9d2f6fab52b1"
14+
branch_labels = None
15+
depends_on = None
16+
17+
18+
def upgrade(engine_name):
19+
globals()[f"upgrade_{engine_name}"]()
20+
21+
22+
def downgrade(engine_name):
23+
globals()[f"downgrade_{engine_name}"]()
24+
25+
26+
def upgrade_registrar():
27+
with op.batch_alter_table("registrarmain") as batch_op:
28+
# SQLite, MySQL and MariaDB do not have a native BOOLEAN datatype but Postgres does. In the former engines, True
29+
# and False are automatically translated to 1 and 0 respectively. In Postgres, attempting to set an INTEGER
30+
# column to True/False results in an error. To ensure consistent behaviour across engines, convert the relevant
31+
# columns to the SQLAlchemy "Boolean" datatype which will automatically use the appropriate engine-native
32+
# datatype (INTEGER for SQLite, TINYINT for MySQL/MariaDB and BOOLEAN for Postgres).
33+
batch_op.alter_column(
34+
"active",
35+
existing_type=sa.Integer,
36+
type_=sa.Boolean,
37+
existing_nullable=True,
38+
postgresql_using="active::boolean",
39+
)
40+
batch_op.alter_column(
41+
"virtual",
42+
existing_type=sa.Integer,
43+
type_=sa.Boolean,
44+
existing_nullable=True,
45+
postgresql_using="virtual::boolean",
46+
)
47+
# Certificates can easily be more than 2048 characters when Base64 encoded. SQLite does not enforce length
48+
# restrictions (VARCHAR(2048) = TEXT) which may have prevented this from being a problem in the past.
49+
# The other engines do enforce these restrictions, so it is better to treat certificates as TEXT columns.
50+
batch_op.alter_column(
51+
"ekcert",
52+
existing_type=sa.String(2048),
53+
type_=sa.Text,
54+
existing_nullable=True,
55+
)
56+
batch_op.alter_column(
57+
"mtls_cert",
58+
existing_type=sa.String(2048),
59+
type_=sa.Text,
60+
existing_nullable=True,
61+
)
62+
63+
64+
def downgrade_registrar():
65+
with op.batch_alter_table("registrarmain") as batch_op:
66+
batch_op.alter_column(
67+
"active",
68+
existing_type=sa.Boolean,
69+
type_=sa.Integer,
70+
existing_nullable=True,
71+
postgresql_using="active::integer",
72+
)
73+
batch_op.alter_column(
74+
"virtual",
75+
existing_type=sa.Boolean,
76+
type_=sa.Integer,
77+
existing_nullable=True,
78+
postgresql_using="virtual::integer",
79+
)
80+
batch_op.alter_column(
81+
"ekcert",
82+
existing_type=sa.Text,
83+
type_=sa.String(2048),
84+
existing_nullable=True,
85+
)
86+
batch_op.alter_column(
87+
"mtls_cert",
88+
existing_type=sa.Text,
89+
type_=sa.String(2048),
90+
existing_nullable=True,
91+
)
92+
93+
94+
def upgrade_cloud_verifier():
95+
pass
96+
97+
98+
def downgrade_cloud_verifier():
99+
pass

keylime/models/__init__.py

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Checks whether script is being invoked by tox in a virtual environment
2+
def is_tox_env() -> bool:
3+
# Import 'os' inside function to avoid polluting the namespace of any module which imports 'keylime.models'
4+
import os # pylint: disable=import-outside-toplevel
5+
6+
return bool(os.environ.get("TOX_ENV_NAME"))
7+
8+
9+
# Only perform automatic imports of submodules if tox is not being used to perform static checks. This is necessary as
10+
# models like RegistrarAgent indirectly import package 'gpg' which is not available in a tox environment as it is
11+
# installed via the system package manager
12+
if not is_tox_env():
13+
from keylime.models.base.da import da_manager
14+
from keylime.models.base.db import db_manager
15+
from keylime.models.registrar import *
16+
17+
__all__ = ["da_manager", "db_manager"]

keylime/models/base/__init__.py

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from sqlalchemy import BigInteger, Boolean, Float, Integer, LargeBinary, SmallInteger, String, Text
2+
3+
from keylime.models.base.basic_model import BasicModel
4+
from keylime.models.base.da import da_manager
5+
from keylime.models.base.db import db_manager
6+
from keylime.models.base.persistable_model import PersistableModel
7+
from keylime.models.base.types.certificate import Certificate
8+
from keylime.models.base.types.dictionary import Dictionary
9+
from keylime.models.base.types.one_of import OneOf
10+
11+
__all__ = [
12+
"BigInteger",
13+
"Boolean",
14+
"Float",
15+
"Integer",
16+
"LargeBinary",
17+
"SmallInteger",
18+
"String",
19+
"Text",
20+
"BasicModel",
21+
"da_manager",
22+
"db_manager",
23+
"PersistableModel",
24+
"Certificate",
25+
"Dictionary",
26+
"OneOf",
27+
]

0 commit comments

Comments
 (0)