Skip to content

Commit

Permalink
Document plugin API
Browse files Browse the repository at this point in the history
  • Loading branch information
MatthiasValvekens committed Apr 5, 2021
1 parent 6cae5a9 commit 24e5bcd
Show file tree
Hide file tree
Showing 4 changed files with 182 additions and 12 deletions.
8 changes: 5 additions & 3 deletions certomancer/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from .registry import (
ExtensionPlugin, extension_plugin_registry, CertomancerConfig, PKIArchitecture
ExtensionPlugin, extension_plugin_registry, CertomancerConfig,
PKIArchitecture, ServicePlugin, service_plugin_registry
)

__all__ = [
'ExtensionPlugin', 'extension_plugin_registry', 'PKIArchitecture',
'CertomancerConfig'
'ExtensionPlugin', 'ServicePlugin',
'extension_plugin_registry', 'service_plugin_registry',
'PKIArchitecture', 'CertomancerConfig'
]
8 changes: 4 additions & 4 deletions certomancer/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ class ExtensionPlugin(abc.ABC):
schema_label: str = None
extension_type: Type[ObjectIdentifier] = None

def provision(self, extn_id: Optional[Type[ObjectIdentifier]],
def provision(self, extn_id: Optional[ObjectIdentifier],
arch: 'PKIArchitecture', params):
"""
Produce a value for an extension identified by ``extn_id``.
Expand All @@ -348,7 +348,7 @@ def provision(self, extn_id: Optional[Type[ObjectIdentifier]],
A parameter object, lifted directly from the input configuration.
Plugins are expected to handle any necessary type checking.
:return:
A value compatible
A value compatible with the targeted extension type.
"""
raise NotImplementedError

Expand Down Expand Up @@ -1509,13 +1509,13 @@ def process_plugin_config(self, params):
"""
return params

def invoke(self, pki_arch: PKIArchitecture, info: PluginServiceInfo,
def invoke(self, arch: PKIArchitecture, info: PluginServiceInfo,
request: bytes, at_time: Optional[datetime] = None) -> bytes:
"""
Invoke the plugin with the specified PKI architecture and service
definition, and feed it data from a request.
:param pki_arch:
:param arch:
PKI architecture context.
:param info:
Parsed service definition object.
Expand Down
170 changes: 169 additions & 1 deletion docs/plugins.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,171 @@
# Certomancer plugin API

*(Under construction)*

There are two types of plugin in Certomancer.

* **Extension plugins** — These are plugins that calculate & format values of certificate
extensions (and other extension types in PKIX).
See [default_plugins.py](../certomancer/default_plugins.py) for a few examples.
* **Service plugins** — These can be used define additional trust services that integrate
with Certomancer in a way that is very similar to the "core" trust services that Certomancer
supports (see [the section on service configuration](config.md#Defining service endpoints)).
You might want to take a look at [this module](../example_plugin/encrypt_echo.py) for a
simple example service plugin.


In general, extension plugins in Certomancer must be **stateless** (or at least, their API should
be), and not assume that they will always be called within the same PKI architecture.
Service plugins should ideally follow the same rules. Plugin instances are created when Certomancer
starts, and will be used throughout the lifetime of the application.
See [further down](#Registering and loading plugins) for details on the loading process.

Problems in plugin configuration should be signalled using the `ConfigurationError` exception class.

## Extension plugin API

Extension plugins inherit from `certomancer.ExtensionPlugin`.
Subclasses are expected to provide a (string) value for the `schema_label` attribute, which
will be used to identify the plugin within Certomancer.
If you expect your plugin to be used only with a certain kind of extensions (e.g. only certificate
extensions), you can set the `extension_type` attribute to the appropriate object identifier class.
This will allow you to do some introspection on the types of extensions that are passed in, but
usually that shouldn't be necessary.

The `provision` method must be implemented by all subclasses, and takes three parameters:

| Parameter | Type | Meaning |
| --- | --- | --- |
|`extn_id` | `ObjectIdentifier` or `None` | The `asn1crypto` object identifier of the extension for which a value is being generated. Can only be provided if `extension_type` is set to an appropriate subclass, and will be `None` otherwise.|
| `arch` | `PKIArchitecture` | The PKI architecture in which the plugin is being invoked. |
| `params` | depends | The parameters with which the plugin was invoked.|

An example:

```yaml
id: subject_alt_name
smart-value:
schema: general-names
params:
- {type: email, value: test@example.com}
- {type: directory-name, value: alice}
```
This will invoke the extension plugin with schema label `general-names`, and the content of `params`
as its parameters. Since the plugin needs to interact with the current PKI architecture to resolve
the entity name `alice`, a reference to the current PKI architecture will be passed via the `arch`
parameter. Since `general-names` is a generic plugin, its `extension_type` is `None`. Therefore,
Certomancer will not attempt to interpret the `subject_alt_name` reference before invoking the
plugin.


## Service plugin API

Service plugins inherit from `certomancer.ServicePlugin`, and have a slightly more involved API.
While Certomancer only exposes services over HTTP right now (and only via `POST`), service plugins
should not rely on any properties of the carrier protocol: the interface is simply
'bytes-in-bytes-out'. The simple `encrypt-echo` [example](../example_plugin/encrypt_echo.py)
should clarify what that means.


Subclasses of `ServicePlugin` are expected to provide a value for the `plugin_label` attribute.
Plugins can also override the `content_type` attribute to indicate what the content type of their
response is. Finally, there are two methods to implement: `process_plugin_config` and `invoke`.

A service definition using plugins in the configuration file looks like this:

```yaml
services:
plugin:
encrypt-echo:
test-endpoint1:
recipient: recipient1
test-endpoint2:
recipient: recipient2
```
Here, `encrypt-echo` is the plugin defined [here](../example_plugin/encrypt_echo.py).

The `process_plugin_config` method is called when Certomancer ingests the configuration file, once
for every endpoint registered for the plugin.
At this stage, the plugin cannot interact with any Certomancer APIs yet.
The only argument (`params`) to this method is the "raw" value of the configuration for the
endpoint, straight from the YAML parser. In the above example, that would be
`{'recipient': 'recipient1'}` for `test-endpoint1`, and `{'recipient': 'recipient2'}` for
`test-endpoint2`.
The `process_plugin_config` can return a more convenient representation of this configuration (e.g.
in a dataclass) for later use. The value returned will be stored in the `params` attribute of
the `PluginServiceInfo` object for that service.

The `invoke` method takes four arguments, and returns the endpoint's response as raw bytes.
If the plugin overrides the `content_type` attribute, the bytes returned should be a valid value
of that media type.

| Parameter | Type | Meaning |
| --- | --- | --- |
| `arch` | `PKIArchitecture` | The PKI architecture in which the service is being invoked. |
| `info` | `PluginServiceInfo` | Contains metadata & configuration about the current endpoint; see below. |
| `request` | `bytes` | Contains the raw request body being passed to the plugin. |
| `at_time` | `datetime` | If not `None`, the plugin should (attempt to) simulate being invoked at the given time. |

A `PluginServiceInfo` object models one endpoint associated with a given plugin.
Besides the configuration options stored in `params`, it also allows the plugin to retrieve the URL
for the endpoint being accessed, among other features.


### Example: `encrypt-echo`

This is all a bit abstract, but a closer look at the [example](../example_plugin/encrypt_echo.py)
and the accompanying [config file](../tests/data/with-plugin.yml) should help with putting the
explanation in context.
An endpoint for the `encrypt-echo` plugin takes only a certificate label as configuration.
This certificate label indicates the recipient.
When a client POSTs to the endpoint, the response body is encrypted, and the envelope key is then
encrypted using the recipient's public key (retrieved from Certomancer).
The recipient's certificate (also provided by Certomancer) is then embedded into the
final `EnvelopedData` CMS structure. Finally, the DER representation of the resulting CMS object
is sent back to the client.

While the use case is obviously a bit contrived, it demonstrates how to integrate Certomancer
with a cryptographic protocol for testing/mocking purposes.

### What if I need to do something more complicated?

Certomancer's plugin system was designed to be easy to integrate with minimal configuration, which
necessarily makes it a bit simplistic. If you want to add more complicated web-based functionality
to Certomancer, you might want to wrap the Animator WSGI application directly.
Combining a strategy like this with a "no-op" implementation of the `ServicePlugin` interface,
you can even keep most of Certomancer's configuration management features, while still allowing you
to do whatever you want in the WSGI layer.

## Registering and loading plugins

Both for extension plugins and for service plugins, there are two steps to take care of:

* registering the plugin.
* making sure the module containing the plugin registration gets executed;

The latter is accomplished by listing the relevant module under `plugin-modules` in the
configuration file (see [here](../tests/data/with-plugin.yml) for an example).
Actually registering the plugin is done using

* `certomancer.extension_plugin_registry.register()` for extension plugins;
* `certomancer.service_plugin_registry.register()` for service plugins.

There are essentially two different ways to pass a plugin to the `register()` function:

* Use the appropriate `register()` function as a class decorator. This requires the class to have
a no-parameter `__init__` method.
* Instantiate the plugin class yourself (possibly with parameters),
and pass the result to `register()`.

In the former case, the plugin registry will immediately instantiate the plugin, and only one
instance will be used throughout the application's lifetime. In the latter case, the code becomes
slightly more verbose, but it does afford some extra flexibilities:

* It allows the plugins to have a nontrivial `__init__` method;
* It allows multiple instances of the same plugin class to coexist (provided that the `plugin_label`
value is different).


Right now, there is no way to pass "global" configuration to a plugin from Certomancer's own config
file, so plugins that depend on complicated initialisation logic should do their own config
management.
8 changes: 4 additions & 4 deletions example_plugin/encrypt_echo.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,13 @@ def process_plugin_config(self, params):
logger.info(f"Found endpoint for recipient {recpt}")
return registry.CertLabel(recpt)

def invoke(self, pki_arch: registry.PKIArchitecture,
info: registry.PluginServiceInfo,
request: bytes, at_time: Optional[datetime] = None) -> bytes:
def invoke(self, arch: registry.PKIArchitecture,
info: registry.PluginServiceInfo, request: bytes,
at_time: Optional[datetime] = None) -> bytes:

cfg = info.plugin_config
assert isinstance(cfg, registry.CertLabel)
cert = pki_arch.get_cert(cfg)
cert = arch.get_cert(cfg)
if cert.public_key.algorithm != 'rsa':
raise registry.CertomancerServiceError(
"This test plugin only works with RSA"
Expand Down

0 comments on commit 24e5bcd

Please sign in to comment.