Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added CRL support #1

Merged
merged 1 commit into from
Oct 31, 2023
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"recommendations": [
"ms-python.python",
"ms-python.vscode-pylance",
"ms-python.black-formatter"
]
}
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -4,7 +4,7 @@

## Description

Certy provides a simple API for creating X509 certificates on demand when running unit tests.
Certy provides a simple API for creating X509 certificates and certificate revocation lists on demand when running unit tests.
No more storing test certificates and private keys in the repository!

Python-certy is a version of similar tool for command line and Golang called [certyaml](https://github.com/tsaarni/certyaml) and [java-certy](https://github.com/tsaarni/java-certy/) for Java.
@@ -25,6 +25,7 @@ cred.write_private_key_as_pem("key.pem")
## Documentation

The latest documentation is available [here](https://tsaarni.github.io/python-certy/).
See also [tests](tests) for more examples.

## Installation

@@ -43,4 +44,4 @@ To find out coverage of tests, execute `coverage run -m pytest` and then `covera
The coverage report is generated to `htmlcov/index.html`.

Run `make html` on `docs` directory to generate documentation.
Open `docs/_build/html/index.html` to view the generated documentation.
Open `docs/_build/html/index.html` to view the generated documentation.
2 changes: 1 addition & 1 deletion docs/index.rst
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
Welcome to certy's documentation!
=================================

Certy provides a simple API for creating X509 certificates on demand when running unit tests.
Certy provides a simple API for creating X509 certificates and certificate revocation lists on demand when running unit tests.
No more storing test certificates and private keys in the repository!

Python-certy is a version of similar tool for command line and Golang called `certyaml`_ and `java-certy`_ for Java.
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -18,3 +18,6 @@ keywords = ["testing", "certificates", "x509", "pki"]
[project.urls]
Documentation = "https://tsaarni.github.io/python-certy/"
Source = "https://github.com/tsaarni/python-certy"

[tool.black]
line-length = 120
25 changes: 24 additions & 1 deletion src/certy/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,30 @@
#
# Copyright Certy Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

"""Certy is a simple X509 certificate generator for unit and integration tests."""

__version__ = "0.1.4"

from .credential import Credential, KeyType, KeyUsage, ExtendedKeyUsage
from .certificate_revocation_list import CertificateRevocationList

__all__ = ["Credential", "KeyType", "KeyUsage", "ExtendedKeyUsage"]
__all__ = [
"Credential",
"KeyType",
"KeyUsage",
"ExtendedKeyUsage",
"CertificateRevocationList",
]
212 changes: 212 additions & 0 deletions src/certy/certificate_revocation_list.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
#
# Copyright Certy Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

from __future__ import annotations

import datetime

from cryptography import x509
from cryptography.hazmat.primitives import serialization

from certy import Credential


class CertificateRevocationList(object):
"""CertificateRevocationList is a builder for X.509 CRLs."""

def __init__(
self,
issuer: Credential | None = None,
revoked_certificates: list[Credential] | None = None,
this_update: datetime.datetime | None = None,
next_update: datetime.datetime | None = None,
):
self._issuer = issuer
self._revoked_certificates = revoked_certificates or []
self._this_update = this_update
self._next_update = next_update

# Generated attributes
self._crl: x509.CertificateRevocationList | None = None

def __repr__(self) -> str:
issuer_name = self._issuer._subject if self._issuer else None
subject_names = [revoked._subject for revoked in self._revoked_certificates]
return f"CertificateRevocationList(issuer={issuer_name!r}, revoked_certificates={subject_names!r}, this_update={self._this_update!r}, next_update={self._next_update!r})"

# Setter methods

def issuer(self, issuer: Credential) -> CertificateRevocationList:
"""Set the issuer of the CRL.

If not called, the issuer will be inferred from the first certificate added to the CRL by calling :meth:`add`.

:param issuer: The issuer of the CRL.
:type issuer: Credential
:return: self
:rtype: CertificateRevocationList
"""
self._issuer = issuer
return self

def this_update(self, this_update: datetime.datetime) -> CertificateRevocationList:
"""Set the ``thisUpdate`` field of the CRL.

If not called, the ``thisUpdate`` field will be set to the current time.

:param this_update: The ``thisUpdate`` field of the CRL.
:type this_update: datetime.datetime
:return: self
:rtype: CertificateRevocationList
"""
self._this_update = this_update
return self

def next_update(self, next_update: datetime.datetime) -> CertificateRevocationList:
"""Set the ``nextUpdate`` field of the CRL.

If not called, the ``nextUpdate`` field will be set to ``thisUpdate`` plus 7 days.

:param next_update: The nextUpdate field of the CRL.
:type next_update: datetime.datetime
:return: self
:rtype: CertificateRevocationList
"""
self._next_update = next_update
return self

def add(self, certificate: Credential) -> CertificateRevocationList:
"""Add a certificate to the CRL.

All certificates added to the CRL must have the same issuer.

:param certificate: The certificate to add to the CRL.
:type certificate: Credential
:return: self
:rtype: CertificateRevocationList
"""

if self._issuer and certificate._issuer != self._issuer:
raise ValueError("issuer mismatch")
if self._revoked_certificates and certificate._issuer != self._revoked_certificates[0]._issuer:
raise ValueError("issuer mismatch")

self._revoked_certificates.append(certificate)
return self

# Builder methods

def generate(self) -> CertificateRevocationList:
"""Generate the CRL.

This method will (re)generate the CRL. It will be called automatically if the CRL is not yet generated when
:meth:`get_as_pem`, :meth:`get_as_der`, :meth:`write_pem` or :meth:`write_der` is called.

:return: self
:rtype: CertificateRevocationList
"""
if not self._issuer:
if not self._revoked_certificates:
raise ValueError("issuer not known: either set issuer or add certificates to the CRL")
if self._revoked_certificates[0]._issuer is None:
raise ValueError("cannot determine issuer from first certificate in CRL")
self._issuer = self._revoked_certificates[0]._issuer

# Ensure that the issuer has a key pair.
self._issuer._ensure_generated()

effective_revocation_time = datetime.datetime.utcnow()
if self._this_update:
effective_revocation_time = self._this_update

effective_expiry_time = effective_revocation_time + datetime.timedelta(days=7)
if self._next_update:
effective_expiry_time = self._next_update

builder = (
x509.CertificateRevocationListBuilder()
.issuer_name(self._issuer._certificate.subject) # type: ignore
.last_update(effective_revocation_time)
.next_update(effective_expiry_time)
)

for certificate in self._revoked_certificates:
certificate._ensure_generated()
builder = builder.add_revoked_certificate(
x509.RevokedCertificateBuilder()
.serial_number(certificate._certificate.serial_number) # type: ignore
.revocation_date(effective_revocation_time)
.build()
)

self._crl = builder.sign(
private_key=self._issuer._private_key, # type: ignore
algorithm=self._issuer._certificate.signature_hash_algorithm, # type: ignore
)

return self

def get_as_pem(self) -> bytes:
"""Get the CRL as PEM.

:return: The CRL as PEM.
:rtype: bytes
"""
self._ensure_generated()
return self._crl.public_bytes(encoding=serialization.Encoding.PEM) # type: ignore

def get_as_der(self) -> bytes:
"""Get the CRL as DER.

:return: The CRL as DER.
:rtype: bytes
"""
self._ensure_generated()
return self._crl.public_bytes(encoding=serialization.Encoding.DER) # type: ignore

def write_pem(self, filename: str) -> CertificateRevocationList:
"""Write the CRL as PEM to a file.

:param filename: The filename to write the CRL to.
:type filename: str
:return: self
:rtype: CertificateRevocationList
"""
self._ensure_generated()
with open(filename, "wb") as f:
f.write(self.get_as_pem())
return self

def write_der(self, filename: str) -> CertificateRevocationList:
"""Write the CRL as DER to a file.

:param filename: The filename to write the CRL to.
:type filename: str
:return: self
:rtype: CertificateRevocationList
"""
self._ensure_generated()
with open(filename, "wb") as f:
f.write(self.get_as_der())
return self

# Helper methods

def _ensure_generated(self) -> CertificateRevocationList:
"""Ensure that the CRL has been generated."""
if not self._crl:
self.generate()
return self
Loading