diff --git a/docs/secret/certificates.md b/docs/secret/certificates.md index 28383c7b..ecf25c15 100644 --- a/docs/secret/certificates.md +++ b/docs/secret/certificates.md @@ -2,8 +2,8 @@ Certificates are a part for the Secret Manager service. The service allows to issue and manage certificates, store them in specified storage and use them for different purposes. -The current implementation only support `dns_core` method (provider) to issue and manage certificates. This method supposed DNS challenges via Core DNS that is -available from the internet. +The certificate resource supports public ACME certificates through `dns_core` +and private service certificates through `internal_ca`. Examples: @@ -33,6 +33,10 @@ The main fields are: - **constructor** - In the context of the certificates, the constructor object creates and stores the certificate. The `plain` means create and store in the plain format. - **email** - the email address to use for the certificate. - **domains** - the list of domains to use for the certificate. +- **cert** - the issued leaf certificate and its chain. +- **key** - the issued leaf private key. +- **ca_cert** - the public CA certificate for `internal_ca` resources. The CA + private key is never exposed by the API or manifest renderer. Also it's possible to specify domains with wildcards. @@ -67,3 +71,33 @@ The `dns_core` provider allows to issue and manage certificates via Core DNS. It - Request a certificate for domains. - Pass the DNS challenge. - Some final preparation. + +### internal_ca + +The `internal_ca` provider issues certificates for services reachable only +inside the Core local network. It creates a private certificate authority in +the Secret Manager backend and returns a hostname-verified server certificate +for the requested DNS names. + +```json +{ + "name": "internal-mail", + "project_id": "00000000-0000-0000-0000-000000000000", + "method": { + "kind": "internal_ca" + }, + "constructor": { + "kind": "plain" + }, + "email": "service@example.com", + "domains": ["mail.internal.example"] +} +``` + +The CA is valid for ten years. Server certificates are valid for 90 days and +are renewed under the same CA when the configured expiration threshold is +reached. Core rotates the CA before a newly issued server certificate would +outlive it; this changes `ca_cert` so client trust configuration is reconciled +alongside the service certificate. Consumers should deliver `key` and `cert` +only to the service node, deliver `ca_cert` to clients, and reload the affected +services when rendered config resources change. diff --git a/exordos_core/agent/universal/drivers/secret/backend/cert.py b/exordos_core/agent/universal/drivers/secret/backend/cert.py index 699513d9..41683bc1 100644 --- a/exordos_core/agent/universal/drivers/secret/backend/cert.py +++ b/exordos_core/agent/universal/drivers/secret/backend/cert.py @@ -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])]) + 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() diff --git a/exordos_core/agent/universal/drivers/secret/dm/models.py b/exordos_core/agent/universal/drivers/secret/dm/models.py index e8e77144..4eef8fec 100644 --- a/exordos_core/agent/universal/drivers/secret/dm/models.py +++ b/exordos_core/agent/universal/drivers/secret/dm/models.py @@ -78,6 +78,14 @@ class Certificate(Secret, orm.SQLStorableMixin): types.String(min_length=1, max_length=10240), required=True, ) + ca_key = properties.property( + types.AllowNone(types.String(min_length=1, max_length=10240)), + default=None, + ) + ca_cert = properties.property( + types.AllowNone(types.String(min_length=1, max_length=10240)), + default=None, + ) expiration_at = properties.property(types.UTCDateTimeZ()) @classmethod @@ -88,6 +96,8 @@ def from_cert_resource( csr_pem: bytes, fullchain_pem: str, expiration_at: datetime.datetime, + ca_key_pem: bytes | None = None, + ca_cert_pem: str | None = None, ) -> "Certificate": meta = resource.value.copy() meta["status"] = sc.SecretStatus.ACTIVE.value @@ -97,6 +107,8 @@ def from_cert_resource( pkey=pkey_pem.decode(), fullchain=fullchain_pem, csr=csr_pem.decode(), + ca_key=None if ca_key_pem is None else ca_key_pem.decode(), + ca_cert=ca_cert_pem, status=sc.SecretStatus.ACTIVE.value, expiration_at=expiration_at, meta=meta, @@ -113,10 +125,12 @@ def to_resource_value(self) -> tp.Dict[str, tp.Any]: expiration_at = self.expiration_at.replace(tzinfo=datetime.timezone.utc) expiration_at = expiration_at.strftime(c.DEFAULT_DATETIME_FORMAT) - value = self.meta + value = self.meta.copy() value["status"] = sc.SecretStatus.ACTIVE.value value["key"] = self.pkey value["cert"] = self.fullchain + if self.ca_cert is not None: + value["ca_cert"] = self.ca_cert value["expiration_at"] = expiration_at value["overcome_threshold"] = self.is_under_threshold() return value diff --git a/exordos_core/secret/dm/models.py b/exordos_core/secret/dm/models.py index 78a46a0a..30d5b1e0 100644 --- a/exordos_core/secret/dm/models.py +++ b/exordos_core/secret/dm/models.py @@ -134,6 +134,10 @@ class DNSCoreCertificateMethod(AbstractCertificateMethod): KIND = "dns_core" +class InternalCACertificateMethod(AbstractCertificateMethod): + KIND = "internal_ca" + + class Certificate( Secret, orm.SQLStorableWithJSONFieldsMixin, @@ -145,6 +149,7 @@ class Certificate( method = properties.property( types_dynamic.KindModelSelectorType( types_dynamic.KindModelType(DNSCoreCertificateMethod), + types_dynamic.KindModelType(InternalCACertificateMethod), ), required=True, default=DNSCoreCertificateMethod, @@ -166,6 +171,10 @@ class Certificate( types.AllowNone(types.String(min_length=1, max_length=10240)), default=None, ) + ca_cert = properties.property( + types.AllowNone(types.String(min_length=1, max_length=10240)), + default=None, + ) # Count of days before expiration when the certificate should be renewed expiration_threshold = properties.property(types.Integer(min_value=0), default=14) # Two meanings: diff --git a/exordos_core/secret/service.py b/exordos_core/secret/service.py index 923b3446..b2256209 100644 --- a/exordos_core/secret/service.py +++ b/exordos_core/secret/service.py @@ -282,12 +282,14 @@ def _actualize_outdated_certificate( status_updated or actual_cert.key != certificate.key or actual_cert.cert != certificate.cert + or actual_cert.ca_cert != certificate.ca_cert or actual_cert.expiration_at != certificate.expiration_at ): if status_updated: certificate.status = actual_cert.status certificate.key = actual_cert.key certificate.cert = actual_cert.cert + certificate.ca_cert = actual_cert.ca_cert certificate.expiration_at = actual_cert.expiration_at certificate.save() certificate_updated = True diff --git a/exordos_core/tests/functional/conftest.py b/exordos_core/tests/functional/conftest.py index ad2ca7b3..c65b9806 100644 --- a/exordos_core/tests/functional/conftest.py +++ b/exordos_core/tests/functional/conftest.py @@ -740,6 +740,7 @@ def factory( email: str = "user@genesis-core.tech", key: tp.Optional[str] = None, cert: tp.Optional[str] = None, + ca_cert: tp.Optional[str] = None, constructor: tp.Optional[secret_models.AbstractSecretConstructor] = None, method: tp.Optional[secret_models.AbstractCertificateMethod] = None, project_id: sys_uuid.UUID = c.SERVICE_PROJECT_ID, @@ -765,6 +766,7 @@ def factory( email=email, key=key, cert=cert, + ca_cert=ca_cert, **kwargs, ) view = obj.dump_to_simple_view() @@ -774,6 +776,8 @@ def factory( view.pop("key") if cert is None: view.pop("cert") + if ca_cert is None: + view.pop("ca_cert") view.pop("expiration_threshold") view.pop("overcome_threshold") diff --git a/exordos_core/tests/functional/restapi/secret/test_certificates.py b/exordos_core/tests/functional/restapi/secret/test_certificates.py index 254d8386..e6cb1a40 100644 --- a/exordos_core/tests/functional/restapi/secret/test_certificates.py +++ b/exordos_core/tests/functional/restapi/secret/test_certificates.py @@ -77,6 +77,25 @@ def test_certificates_add( client.build_resource_uri(["secret/certificates", output["uuid"]]) ) + def test_internal_ca_certificate_method_is_accepted( + self, + cert_factory: tp.Callable, + user_api_client: iam_clients.GenesisCoreTestRESTClient, + auth_user_admin: iam_clients.GenesisCoreAuth, + ): + client = user_api_client(auth_user_admin) + cert = cert_factory(method=secret_models.InternalCACertificateMethod()) + url = client.build_collection_uri(["secret/certificates"]) + + response = client.post(url, json=cert) + output = response.json() + + assert response.status_code == 201 + assert output["method"] == {"kind": "internal_ca"} + client.delete( + client.build_resource_uri(["secret/certificates", output["uuid"]]) + ) + def test_certificates_add_several( self, cert_factory: tp.Callable, diff --git a/exordos_core/tests/functional/service/test_secrets.py b/exordos_core/tests/functional/service/test_secrets.py index 1ca032ba..66837394 100644 --- a/exordos_core/tests/functional/service/test_secrets.py +++ b/exordos_core/tests/functional/service/test_secrets.py @@ -119,6 +119,7 @@ def test_in_progress_certificates( view["value"]["status"] = "ACTIVE" view["value"]["key"] = "mykey" view["value"]["cert"] = "mycert" + view["value"]["ca_cert"] = "my-ca-cert" render_actual_resource = ua_models.Resource.restore_from_simple_view(**view) render_actual_resource.insert() @@ -128,6 +129,7 @@ def test_in_progress_certificates( assert certificate.status == "ACTIVE" assert certificate.key == "mykey" assert certificate.cert == "mycert" + assert certificate.ca_cert == "my-ca-cert" cert_url = client.build_resource_uri(["secret/certificates", certificate_uuid]) client.delete(cert_url) diff --git a/exordos_core/tests/unit/secret/test_internal_ca_certificate.py b/exordos_core/tests/unit/secret/test_internal_ca_certificate.py new file mode 100644 index 00000000..ec92625d --- /dev/null +++ b/exordos_core/tests/unit/secret/test_internal_ca_certificate.py @@ -0,0 +1,101 @@ +# Copyright 2026 Genesis Corporation. +# +# All Rights Reserved. +# +# 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. + +import datetime +import types +import uuid as sys_uuid + +from cryptography import x509 +from cryptography.hazmat.primitives.asymmetric import padding + +from exordos_core.agent.universal.drivers.secret.backend import cert + + +def _resource(): + return types.SimpleNamespace( + uuid=sys_uuid.uuid4(), + value={ + "name": "workspace-mail", + "domains": ["workspace-mail.internal.example"], + "method": {"kind": "internal_ca"}, + "expiration_threshold": 14, + }, + ) + + +def _leaf(fullchain): + leaf_pem, _ = fullchain.split("-----END CERTIFICATE-----", 1) + return x509.load_pem_x509_certificate( + f"{leaf_pem}-----END CERTIFICATE-----".encode() + ) + + +def test_internal_ca_issues_hostname_verified_server_certificate(): + resource = _resource() + + issued = cert._issue_internal_certificate(resource) + + leaf = _leaf(issued.fullchain) + ca = x509.load_pem_x509_certificate(issued.ca_cert.encode()) + ca.public_key().verify( + leaf.signature, + leaf.tbs_certificate_bytes, + padding.PKCS1v15(), + leaf.signature_hash_algorithm, + ) + assert ( + leaf.extensions.get_extension_for_class( + x509.SubjectAlternativeName + ).value.get_values_for_type(x509.DNSName) + == resource.value["domains"] + ) + assert ca.extensions.get_extension_for_class(x509.BasicConstraints).value.ca is True + assert issued.ca_key is not None + assert issued.to_resource_value()["ca_cert"] == issued.ca_cert + assert "ca_key" not in issued.to_resource_value() + + +def test_internal_ca_rotation_keeps_ca_and_replaces_leaf(): + resource = _resource() + first = cert._issue_internal_certificate(resource) + + rotated = cert._issue_internal_certificate( + resource, + first.ca_key.encode(), + first.ca_cert, + ) + + assert rotated.ca_cert == first.ca_cert + assert rotated.ca_key == first.ca_key + assert ( + _leaf(rotated.fullchain).serial_number != _leaf(first.fullchain).serial_number + ) + assert rotated.pkey != first.pkey + + +def test_internal_ca_rotates_before_a_new_leaf_would_outlive_it(monkeypatch): + resource = _resource() + monkeypatch.setattr(cert, "INTERNAL_CA_VALIDITY", datetime.timedelta(days=1)) + first = cert._issue_internal_certificate(resource) + + rotated = cert._issue_internal_certificate( + resource, + first.ca_key.encode(), + first.ca_cert, + ) + + assert rotated.ca_cert != first.ca_cert + assert rotated.ca_key != first.ca_key diff --git a/exordos_core/user_api/secret/api/controllers.py b/exordos_core/user_api/secret/api/controllers.py index c528b00e..b488680b 100644 --- a/exordos_core/user_api/secret/api/controllers.py +++ b/exordos_core/user_api/secret/api/controllers.py @@ -130,6 +130,7 @@ class CertificatesController(iam_controllers.PolicyBasedController): "status": {ra_c.ALL: field_p.Permissions.RO}, "key": {ra_c.ALL: field_p.Permissions.RO}, "cert": {ra_c.ALL: field_p.Permissions.RO}, + "ca_cert": {ra_c.ALL: field_p.Permissions.RO}, "expiration_threshold": {ra_c.ALL: field_p.Permissions.HIDDEN}, "overcome_threshold": {ra_c.ALL: field_p.Permissions.HIDDEN}, }, diff --git a/migrations/0066-internal-ca-certificates-746143.py b/migrations/0066-internal-ca-certificates-746143.py new file mode 100644 index 00000000..071ad2b9 --- /dev/null +++ b/migrations/0066-internal-ca-certificates-746143.py @@ -0,0 +1,62 @@ +# Copyright 2026 Genesis Corporation. +# +# All Rights Reserved. +# +# 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 restalchemy.storage.sql import migrations + + +class MigrationStep(migrations.AbstractMigrationStep): + def __init__(self): + self._depends = ["0065-registration-client-auto-provision-b7f2d9.py"] + + @property + def migration_id(self): + return "746143b5-3bf1-41d0-af58-5e9461767811" + + @property + def is_manual(self): + return False + + def upgrade(self, session): + session.execute( + "ALTER TABLE secret_certificates " + "ADD COLUMN IF NOT EXISTS ca_cert TEXT NULL", + None, + ) + session.execute( + "ALTER TABLE storage_certs ADD COLUMN IF NOT EXISTS ca_key TEXT NULL", + None, + ) + session.execute( + "ALTER TABLE storage_certs ADD COLUMN IF NOT EXISTS ca_cert TEXT NULL", + None, + ) + + def downgrade(self, session): + session.execute( + "ALTER TABLE storage_certs DROP COLUMN IF EXISTS ca_cert", + None, + ) + session.execute( + "ALTER TABLE storage_certs DROP COLUMN IF EXISTS ca_key", + None, + ) + session.execute( + "ALTER TABLE secret_certificates DROP COLUMN IF EXISTS ca_cert", + None, + ) + + +migration_step = MigrationStep()