Skip to content
Draft
Show file tree
Hide file tree
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
38 changes: 36 additions & 2 deletions docs/secret/certificates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.
166 changes: 165 additions & 1 deletion exordos_core/agent/universal/drivers/secret/backend/cert.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
),
]
)
Comment on lines +62 to +70

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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 cause cryptography to raise a ValueError during certificate generation. We should truncate the name to ensure the CN is always within the 64-character limit.

Suggested change
subject = x509.Name(
[
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Exordos Internal"),
x509.NameAttribute(
NameOID.COMMON_NAME,
f"{resource.value['name']} CA",
),
]
)
common_name = f"{resource.value['name']} CA"
if len(common_name) > 64:
common_name = f"{resource.value['name'][:60]} CA"
subject = x509.Name(
[
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Exordos Internal"),
x509.NameAttribute(NameOID.COMMON_NAME, common_name),
]
)

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The Common Name (CN) in X.509 certificates is limited to 64 characters. If the first domain name in domains exceeds 64 characters, cryptography will raise a ValueError. We should truncate the domain name to 64 characters when setting it as the Common Name. (Note that modern TLS clients rely on Subject Alternative Names (SAN) anyway, so truncating the CN is safe and standard practice).

    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."""
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
16 changes: 15 additions & 1 deletion exordos_core/agent/universal/drivers/secret/dm/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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
9 changes: 9 additions & 0 deletions exordos_core/secret/dm/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,10 @@ class DNSCoreCertificateMethod(AbstractCertificateMethod):
KIND = "dns_core"


class InternalCACertificateMethod(AbstractCertificateMethod):
KIND = "internal_ca"


class Certificate(
Secret,
orm.SQLStorableWithJSONFieldsMixin,
Expand All @@ -145,6 +149,7 @@ class Certificate(
method = properties.property(
types_dynamic.KindModelSelectorType(
types_dynamic.KindModelType(DNSCoreCertificateMethod),
types_dynamic.KindModelType(InternalCACertificateMethod),
),
required=True,
default=DNSCoreCertificateMethod,
Expand All @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions exordos_core/secret/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions exordos_core/tests/functional/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -765,6 +766,7 @@ def factory(
email=email,
key=key,
cert=cert,
ca_cert=ca_cert,
**kwargs,
)
view = obj.dump_to_simple_view()
Expand All @@ -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")

Expand Down
Loading