-
Notifications
You must be signed in to change notification settings - Fork 2
Add internal CA certificate provider #490
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
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,10 +14,16 @@ | |
| # License for the specific language governing permissions and limitations | ||
| # under the License. | ||
|
|
||
| import datetime | ||
| import logging | ||
| import typing as tp | ||
|
|
||
| from cryptography import x509 | ||
| from cryptography.hazmat.primitives import hashes | ||
| from cryptography.hazmat.primitives import serialization | ||
| from cryptography.hazmat.primitives.asymmetric import rsa | ||
| from cryptography.x509.oid import ExtendedKeyUsageOID | ||
| from cryptography.x509.oid import NameOID | ||
| from gcl_certbot_plugin import acme | ||
| from gcl_certbot_plugin import clients as dns_clients | ||
| from gcl_certbot_plugin.acme import acme_lib_client | ||
|
|
@@ -32,6 +38,141 @@ | |
|
|
||
| LOG = logging.getLogger(__name__) | ||
|
|
||
| INTERNAL_CA_VALIDITY = datetime.timedelta(days=3650) | ||
| INTERNAL_CERTIFICATE_VALIDITY = datetime.timedelta(days=90) | ||
| INTERNAL_CA_RENEWAL_THRESHOLD = INTERNAL_CERTIFICATE_VALIDITY + datetime.timedelta( | ||
| days=14 | ||
| ) | ||
|
|
||
|
|
||
| def _pem_private_key(private_key: rsa.RSAPrivateKey) -> bytes: | ||
| return private_key.private_bytes( | ||
| encoding=serialization.Encoding.PEM, | ||
| format=serialization.PrivateFormat.PKCS8, | ||
| encryption_algorithm=serialization.NoEncryption(), | ||
| ) | ||
|
|
||
|
|
||
| def _pem_certificate(certificate: x509.Certificate) -> str: | ||
| return certificate.public_bytes(serialization.Encoding.PEM).decode() | ||
|
|
||
|
|
||
| def _internal_ca(resource: models.Resource) -> tuple[bytes, str]: | ||
| private_key = rsa.generate_private_key(public_exponent=65537, key_size=3072) | ||
| subject = x509.Name( | ||
| [ | ||
| x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Exordos Internal"), | ||
| x509.NameAttribute( | ||
| NameOID.COMMON_NAME, | ||
| f"{resource.value['name']} CA", | ||
| ), | ||
| ] | ||
| ) | ||
| now = datetime.datetime.now(datetime.timezone.utc) | ||
| certificate = ( | ||
| x509.CertificateBuilder() | ||
| .subject_name(subject) | ||
| .issuer_name(subject) | ||
| .public_key(private_key.public_key()) | ||
| .serial_number(x509.random_serial_number()) | ||
| .not_valid_before(now - datetime.timedelta(minutes=5)) | ||
| .not_valid_after(now + INTERNAL_CA_VALIDITY) | ||
| .add_extension( | ||
| x509.BasicConstraints(ca=True, path_length=0), | ||
| critical=True, | ||
| ) | ||
| .add_extension( | ||
| x509.KeyUsage( | ||
| digital_signature=True, | ||
| content_commitment=False, | ||
| key_encipherment=False, | ||
| data_encipherment=False, | ||
| key_agreement=False, | ||
| key_cert_sign=True, | ||
| crl_sign=True, | ||
| encipher_only=False, | ||
| decipher_only=False, | ||
| ), | ||
| critical=True, | ||
| ) | ||
| .sign(private_key, hashes.SHA256()) | ||
| ) | ||
| return _pem_private_key(private_key), _pem_certificate(certificate) | ||
|
|
||
|
|
||
| def _issue_internal_certificate( | ||
| resource: models.Resource, | ||
| ca_key_pem: bytes | None = None, | ||
| ca_cert_pem: str | None = None, | ||
| ) -> driver_dm.Certificate: | ||
| if ca_cert_pem is not None: | ||
| ca_cert = x509.load_pem_x509_certificate(ca_cert_pem.encode()) | ||
| now = datetime.datetime.now(datetime.timezone.utc) | ||
| if ca_cert.not_valid_after_utc <= now + INTERNAL_CA_RENEWAL_THRESHOLD: | ||
| ca_key_pem = None | ||
| ca_cert_pem = None | ||
| if ca_key_pem is None or ca_cert_pem is None: | ||
| ca_key_pem, ca_cert_pem = _internal_ca(resource) | ||
| ca_key = tp.cast( | ||
| rsa.RSAPrivateKey, | ||
| serialization.load_pem_private_key(ca_key_pem, password=None), | ||
| ) | ||
| ca_cert = x509.load_pem_x509_certificate(ca_cert_pem.encode()) | ||
| private_key = rsa.generate_private_key(public_exponent=65537, key_size=3072) | ||
| domains = resource.value["domains"] | ||
| subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, domains[0])]) | ||
|
Comment on lines
+122
to
+123
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Common Name (CN) in X.509 certificates is limited to 64 characters. If the first domain name in domains = resource.value["domains"]
common_name = domains[0]
if len(common_name) > 64:
common_name = common_name[:64]
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)]) |
||
| san = x509.SubjectAlternativeName([x509.DNSName(domain) for domain in domains]) | ||
| csr = ( | ||
| x509.CertificateSigningRequestBuilder() | ||
| .subject_name(subject) | ||
| .add_extension(san, critical=False) | ||
| .sign(private_key, hashes.SHA256()) | ||
| ) | ||
| now = datetime.datetime.now(datetime.timezone.utc) | ||
| certificate = ( | ||
| x509.CertificateBuilder() | ||
| .subject_name(subject) | ||
| .issuer_name(ca_cert.subject) | ||
| .public_key(private_key.public_key()) | ||
| .serial_number(x509.random_serial_number()) | ||
| .not_valid_before(now - datetime.timedelta(minutes=5)) | ||
| .not_valid_after(now + INTERNAL_CERTIFICATE_VALIDITY) | ||
| .add_extension(san, critical=False) | ||
| .add_extension( | ||
| x509.BasicConstraints(ca=False, path_length=None), | ||
| critical=True, | ||
| ) | ||
| .add_extension( | ||
| x509.KeyUsage( | ||
| digital_signature=True, | ||
| content_commitment=False, | ||
| key_encipherment=True, | ||
| data_encipherment=False, | ||
| key_agreement=False, | ||
| key_cert_sign=False, | ||
| crl_sign=False, | ||
| encipher_only=False, | ||
| decipher_only=False, | ||
| ), | ||
| critical=True, | ||
| ) | ||
| .add_extension( | ||
| x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), | ||
| critical=False, | ||
| ) | ||
| .sign(ca_key, hashes.SHA256()) | ||
| ) | ||
| certificate_pem = _pem_certificate(certificate) | ||
| return driver_dm.Certificate.from_cert_resource( | ||
| resource, | ||
| _pem_private_key(private_key), | ||
| csr.public_bytes(serialization.Encoding.PEM), | ||
| certificate_pem + ca_cert_pem, | ||
| certificate.not_valid_after_utc, | ||
| ca_key_pem=ca_key_pem, | ||
| ca_cert_pem=ca_cert_pem, | ||
| ) | ||
|
|
||
|
|
||
| class CertBotBackendClient(base.AbstractBackendClient): | ||
| """Cert bot backend client.""" | ||
|
|
@@ -80,6 +221,11 @@ def create(self, resource: models.Resource) -> tp.Dict[str, tp.Any]: | |
|
|
||
| cert = secret_dm.Certificate.from_ua_resource(resource) | ||
|
|
||
| if isinstance(cert.method, secret_dm.InternalCACertificateMethod): | ||
| driver_cert = _issue_internal_certificate(resource) | ||
| driver_cert.save() | ||
| return driver_cert.to_resource_value() | ||
|
|
||
| # Create cert via DNS | ||
| pkey_pem, csr_pem, fullchain_pem = acme.create_cert( | ||
| self._get_or_create_acme_client(), | ||
|
|
@@ -108,6 +254,23 @@ def update(self, resource: models.Resource) -> tp.Dict[str, tp.Any]: | |
| except ra_exc.RecordNotFound: | ||
| raise exceptions.ResourceNotFound(resource=resource) | ||
|
|
||
| method = cert["meta"]["method"]["kind"] | ||
| if method == secret_dm.InternalCACertificateMethod.KIND: | ||
| if ( | ||
| set(cert["meta"]["domains"]) == set(resource.value["domains"]) | ||
| and not cert.is_under_threshold() | ||
| ): | ||
| return cert.to_resource_value() | ||
| ca_key_pem = None if cert.ca_key is None else cert.ca_key.encode() | ||
| driver_cert = _issue_internal_certificate( | ||
| resource, | ||
| ca_key_pem, | ||
| cert.ca_cert, | ||
| ) | ||
| cert.delete() | ||
| driver_cert.save() | ||
| return driver_cert.to_resource_value() | ||
|
|
||
| # TODO(akremenetsky): It's tricky logic to update the cert | ||
| # if domains changed. Need to check domains intersection, | ||
| # check wildcards | ||
|
|
@@ -155,5 +318,6 @@ def delete(self, resource: models.Resource) -> None: | |
| "uuid": dm_filters.EQ(resource.uuid), | ||
| } | ||
| ) | ||
| acme.revoke_cert(self._get_or_create_acme_client(), cert.fullchain) | ||
| if cert["meta"]["method"]["kind"] != secret_dm.InternalCACertificateMethod.KIND: | ||
| acme.revoke_cert(self._get_or_create_acme_client(), cert.fullchain) | ||
| cert.delete() | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The Common Name (CN) in X.509 certificates has a maximum length of 64 characters per RFC 5280. If
resource.value['name']is long, appending' CA'to it can exceed this limit and causecryptographyto raise aValueErrorduring certificate generation. We should truncate the name to ensure the CN is always within the 64-character limit.