Skip to content

Commit 1f39c0b

Browse files
committed
Add internal CA certificate provider
1 parent 54fea43 commit 1f39c0b

11 files changed

Lines changed: 416 additions & 4 deletions

File tree

docs/secret/certificates.md

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
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.
44

5-
The current implementation only support `dns_core` method (provider) to issue and manage certificates. This method supposed DNS challenges via Core DNS that is
6-
available from the internet.
5+
The certificate resource supports public ACME certificates through `dns_core`
6+
and private service certificates through `internal_ca`.
77

88
Examples:
99

@@ -33,6 +33,10 @@ The main fields are:
3333
- **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.
3434
- **email** - the email address to use for the certificate.
3535
- **domains** - the list of domains to use for the certificate.
36+
- **cert** - the issued leaf certificate and its chain.
37+
- **key** - the issued leaf private key.
38+
- **ca_cert** - the public CA certificate for `internal_ca` resources. The CA
39+
private key is never exposed by the API or manifest renderer.
3640

3741
Also it's possible to specify domains with wildcards.
3842

@@ -67,3 +71,33 @@ The `dns_core` provider allows to issue and manage certificates via Core DNS. It
6771
- Request a certificate for domains.
6872
- Pass the DNS challenge.
6973
- Some final preparation.
74+
75+
### internal_ca
76+
77+
The `internal_ca` provider issues certificates for services reachable only
78+
inside the Core local network. It creates a private certificate authority in
79+
the Secret Manager backend and returns a hostname-verified server certificate
80+
for the requested DNS names.
81+
82+
```json
83+
{
84+
"name": "internal-mail",
85+
"project_id": "00000000-0000-0000-0000-000000000000",
86+
"method": {
87+
"kind": "internal_ca"
88+
},
89+
"constructor": {
90+
"kind": "plain"
91+
},
92+
"email": "service@example.com",
93+
"domains": ["mail.internal.example"]
94+
}
95+
```
96+
97+
The CA is valid for ten years. Server certificates are valid for 90 days and
98+
are renewed under the same CA when the configured expiration threshold is
99+
reached. Core rotates the CA before a newly issued server certificate would
100+
outlive it; this changes `ca_cert` so client trust configuration is reconciled
101+
alongside the service certificate. Consumers should deliver `key` and `cert`
102+
only to the service node, deliver `ca_cert` to clients, and reload the affected
103+
services when rendered config resources change.

exordos_core/agent/universal/drivers/secret/backend/cert.py

Lines changed: 165 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,16 @@
1414
# License for the specific language governing permissions and limitations
1515
# under the License.
1616

17+
import datetime
1718
import logging
1819
import typing as tp
1920

2021
from cryptography import x509
22+
from cryptography.hazmat.primitives import hashes
23+
from cryptography.hazmat.primitives import serialization
24+
from cryptography.hazmat.primitives.asymmetric import rsa
25+
from cryptography.x509.oid import ExtendedKeyUsageOID
26+
from cryptography.x509.oid import NameOID
2127
from gcl_certbot_plugin import acme
2228
from gcl_certbot_plugin import clients as dns_clients
2329
from gcl_certbot_plugin.acme import acme_lib_client
@@ -32,6 +38,141 @@
3238

3339
LOG = logging.getLogger(__name__)
3440

41+
INTERNAL_CA_VALIDITY = datetime.timedelta(days=3650)
42+
INTERNAL_CERTIFICATE_VALIDITY = datetime.timedelta(days=90)
43+
INTERNAL_CA_RENEWAL_THRESHOLD = INTERNAL_CERTIFICATE_VALIDITY + datetime.timedelta(
44+
days=14
45+
)
46+
47+
48+
def _pem_private_key(private_key: rsa.RSAPrivateKey) -> bytes:
49+
return private_key.private_bytes(
50+
encoding=serialization.Encoding.PEM,
51+
format=serialization.PrivateFormat.PKCS8,
52+
encryption_algorithm=serialization.NoEncryption(),
53+
)
54+
55+
56+
def _pem_certificate(certificate: x509.Certificate) -> str:
57+
return certificate.public_bytes(serialization.Encoding.PEM).decode()
58+
59+
60+
def _internal_ca(resource: models.Resource) -> tuple[bytes, str]:
61+
private_key = rsa.generate_private_key(public_exponent=65537, key_size=3072)
62+
subject = x509.Name(
63+
[
64+
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Exordos Internal"),
65+
x509.NameAttribute(
66+
NameOID.COMMON_NAME,
67+
f"{resource.value['name']} CA",
68+
),
69+
]
70+
)
71+
now = datetime.datetime.now(datetime.timezone.utc)
72+
certificate = (
73+
x509.CertificateBuilder()
74+
.subject_name(subject)
75+
.issuer_name(subject)
76+
.public_key(private_key.public_key())
77+
.serial_number(x509.random_serial_number())
78+
.not_valid_before(now - datetime.timedelta(minutes=5))
79+
.not_valid_after(now + INTERNAL_CA_VALIDITY)
80+
.add_extension(
81+
x509.BasicConstraints(ca=True, path_length=0),
82+
critical=True,
83+
)
84+
.add_extension(
85+
x509.KeyUsage(
86+
digital_signature=True,
87+
content_commitment=False,
88+
key_encipherment=False,
89+
data_encipherment=False,
90+
key_agreement=False,
91+
key_cert_sign=True,
92+
crl_sign=True,
93+
encipher_only=False,
94+
decipher_only=False,
95+
),
96+
critical=True,
97+
)
98+
.sign(private_key, hashes.SHA256())
99+
)
100+
return _pem_private_key(private_key), _pem_certificate(certificate)
101+
102+
103+
def _issue_internal_certificate(
104+
resource: models.Resource,
105+
ca_key_pem: bytes | None = None,
106+
ca_cert_pem: str | None = None,
107+
) -> driver_dm.Certificate:
108+
if ca_cert_pem is not None:
109+
ca_cert = x509.load_pem_x509_certificate(ca_cert_pem.encode())
110+
now = datetime.datetime.now(datetime.timezone.utc)
111+
if ca_cert.not_valid_after_utc <= now + INTERNAL_CA_RENEWAL_THRESHOLD:
112+
ca_key_pem = None
113+
ca_cert_pem = None
114+
if ca_key_pem is None or ca_cert_pem is None:
115+
ca_key_pem, ca_cert_pem = _internal_ca(resource)
116+
ca_key = tp.cast(
117+
rsa.RSAPrivateKey,
118+
serialization.load_pem_private_key(ca_key_pem, password=None),
119+
)
120+
ca_cert = x509.load_pem_x509_certificate(ca_cert_pem.encode())
121+
private_key = rsa.generate_private_key(public_exponent=65537, key_size=3072)
122+
domains = resource.value["domains"]
123+
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, domains[0])])
124+
san = x509.SubjectAlternativeName([x509.DNSName(domain) for domain in domains])
125+
csr = (
126+
x509.CertificateSigningRequestBuilder()
127+
.subject_name(subject)
128+
.add_extension(san, critical=False)
129+
.sign(private_key, hashes.SHA256())
130+
)
131+
now = datetime.datetime.now(datetime.timezone.utc)
132+
certificate = (
133+
x509.CertificateBuilder()
134+
.subject_name(subject)
135+
.issuer_name(ca_cert.subject)
136+
.public_key(private_key.public_key())
137+
.serial_number(x509.random_serial_number())
138+
.not_valid_before(now - datetime.timedelta(minutes=5))
139+
.not_valid_after(now + INTERNAL_CERTIFICATE_VALIDITY)
140+
.add_extension(san, critical=False)
141+
.add_extension(
142+
x509.BasicConstraints(ca=False, path_length=None),
143+
critical=True,
144+
)
145+
.add_extension(
146+
x509.KeyUsage(
147+
digital_signature=True,
148+
content_commitment=False,
149+
key_encipherment=True,
150+
data_encipherment=False,
151+
key_agreement=False,
152+
key_cert_sign=False,
153+
crl_sign=False,
154+
encipher_only=False,
155+
decipher_only=False,
156+
),
157+
critical=True,
158+
)
159+
.add_extension(
160+
x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]),
161+
critical=False,
162+
)
163+
.sign(ca_key, hashes.SHA256())
164+
)
165+
certificate_pem = _pem_certificate(certificate)
166+
return driver_dm.Certificate.from_cert_resource(
167+
resource,
168+
_pem_private_key(private_key),
169+
csr.public_bytes(serialization.Encoding.PEM),
170+
certificate_pem + ca_cert_pem,
171+
certificate.not_valid_after_utc,
172+
ca_key_pem=ca_key_pem,
173+
ca_cert_pem=ca_cert_pem,
174+
)
175+
35176

36177
class CertBotBackendClient(base.AbstractBackendClient):
37178
"""Cert bot backend client."""
@@ -80,6 +221,11 @@ def create(self, resource: models.Resource) -> tp.Dict[str, tp.Any]:
80221

81222
cert = secret_dm.Certificate.from_ua_resource(resource)
82223

224+
if isinstance(cert.method, secret_dm.InternalCACertificateMethod):
225+
driver_cert = _issue_internal_certificate(resource)
226+
driver_cert.save()
227+
return driver_cert.to_resource_value()
228+
83229
# Create cert via DNS
84230
pkey_pem, csr_pem, fullchain_pem = acme.create_cert(
85231
self._get_or_create_acme_client(),
@@ -108,6 +254,23 @@ def update(self, resource: models.Resource) -> tp.Dict[str, tp.Any]:
108254
except ra_exc.RecordNotFound:
109255
raise exceptions.ResourceNotFound(resource=resource)
110256

257+
method = cert["meta"]["method"]["kind"]
258+
if method == secret_dm.InternalCACertificateMethod.KIND:
259+
if (
260+
set(cert["meta"]["domains"]) == set(resource.value["domains"])
261+
and not cert.is_under_threshold()
262+
):
263+
return cert.to_resource_value()
264+
ca_key_pem = None if cert.ca_key is None else cert.ca_key.encode()
265+
driver_cert = _issue_internal_certificate(
266+
resource,
267+
ca_key_pem,
268+
cert.ca_cert,
269+
)
270+
cert.delete()
271+
driver_cert.save()
272+
return driver_cert.to_resource_value()
273+
111274
# TODO(akremenetsky): It's tricky logic to update the cert
112275
# if domains changed. Need to check domains intersection,
113276
# check wildcards
@@ -155,5 +318,6 @@ def delete(self, resource: models.Resource) -> None:
155318
"uuid": dm_filters.EQ(resource.uuid),
156319
}
157320
)
158-
acme.revoke_cert(self._get_or_create_acme_client(), cert.fullchain)
321+
if cert["meta"]["method"]["kind"] != secret_dm.InternalCACertificateMethod.KIND:
322+
acme.revoke_cert(self._get_or_create_acme_client(), cert.fullchain)
159323
cert.delete()

exordos_core/agent/universal/drivers/secret/dm/models.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,14 @@ class Certificate(Secret, orm.SQLStorableMixin):
7878
types.String(min_length=1, max_length=10240),
7979
required=True,
8080
)
81+
ca_key = properties.property(
82+
types.AllowNone(types.String(min_length=1, max_length=10240)),
83+
default=None,
84+
)
85+
ca_cert = properties.property(
86+
types.AllowNone(types.String(min_length=1, max_length=10240)),
87+
default=None,
88+
)
8189
expiration_at = properties.property(types.UTCDateTimeZ())
8290

8391
@classmethod
@@ -88,6 +96,8 @@ def from_cert_resource(
8896
csr_pem: bytes,
8997
fullchain_pem: str,
9098
expiration_at: datetime.datetime,
99+
ca_key_pem: bytes | None = None,
100+
ca_cert_pem: str | None = None,
91101
) -> "Certificate":
92102
meta = resource.value.copy()
93103
meta["status"] = sc.SecretStatus.ACTIVE.value
@@ -97,6 +107,8 @@ def from_cert_resource(
97107
pkey=pkey_pem.decode(),
98108
fullchain=fullchain_pem,
99109
csr=csr_pem.decode(),
110+
ca_key=None if ca_key_pem is None else ca_key_pem.decode(),
111+
ca_cert=ca_cert_pem,
100112
status=sc.SecretStatus.ACTIVE.value,
101113
expiration_at=expiration_at,
102114
meta=meta,
@@ -113,10 +125,12 @@ def to_resource_value(self) -> tp.Dict[str, tp.Any]:
113125
expiration_at = self.expiration_at.replace(tzinfo=datetime.timezone.utc)
114126
expiration_at = expiration_at.strftime(c.DEFAULT_DATETIME_FORMAT)
115127

116-
value = self.meta
128+
value = self.meta.copy()
117129
value["status"] = sc.SecretStatus.ACTIVE.value
118130
value["key"] = self.pkey
119131
value["cert"] = self.fullchain
132+
if self.ca_cert is not None:
133+
value["ca_cert"] = self.ca_cert
120134
value["expiration_at"] = expiration_at
121135
value["overcome_threshold"] = self.is_under_threshold()
122136
return value

exordos_core/secret/dm/models.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,10 @@ class DNSCoreCertificateMethod(AbstractCertificateMethod):
134134
KIND = "dns_core"
135135

136136

137+
class InternalCACertificateMethod(AbstractCertificateMethod):
138+
KIND = "internal_ca"
139+
140+
137141
class Certificate(
138142
Secret,
139143
orm.SQLStorableWithJSONFieldsMixin,
@@ -145,6 +149,7 @@ class Certificate(
145149
method = properties.property(
146150
types_dynamic.KindModelSelectorType(
147151
types_dynamic.KindModelType(DNSCoreCertificateMethod),
152+
types_dynamic.KindModelType(InternalCACertificateMethod),
148153
),
149154
required=True,
150155
default=DNSCoreCertificateMethod,
@@ -166,6 +171,10 @@ class Certificate(
166171
types.AllowNone(types.String(min_length=1, max_length=10240)),
167172
default=None,
168173
)
174+
ca_cert = properties.property(
175+
types.AllowNone(types.String(min_length=1, max_length=10240)),
176+
default=None,
177+
)
169178
# Count of days before expiration when the certificate should be renewed
170179
expiration_threshold = properties.property(types.Integer(min_value=0), default=14)
171180
# Two meanings:

exordos_core/secret/service.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,12 +282,14 @@ def _actualize_outdated_certificate(
282282
status_updated
283283
or actual_cert.key != certificate.key
284284
or actual_cert.cert != certificate.cert
285+
or actual_cert.ca_cert != certificate.ca_cert
285286
or actual_cert.expiration_at != certificate.expiration_at
286287
):
287288
if status_updated:
288289
certificate.status = actual_cert.status
289290
certificate.key = actual_cert.key
290291
certificate.cert = actual_cert.cert
292+
certificate.ca_cert = actual_cert.ca_cert
291293
certificate.expiration_at = actual_cert.expiration_at
292294
certificate.save()
293295
certificate_updated = True

exordos_core/tests/functional/conftest.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,7 @@ def factory(
740740
email: str = "user@genesis-core.tech",
741741
key: tp.Optional[str] = None,
742742
cert: tp.Optional[str] = None,
743+
ca_cert: tp.Optional[str] = None,
743744
constructor: tp.Optional[secret_models.AbstractSecretConstructor] = None,
744745
method: tp.Optional[secret_models.AbstractCertificateMethod] = None,
745746
project_id: sys_uuid.UUID = c.SERVICE_PROJECT_ID,
@@ -765,6 +766,7 @@ def factory(
765766
email=email,
766767
key=key,
767768
cert=cert,
769+
ca_cert=ca_cert,
768770
**kwargs,
769771
)
770772
view = obj.dump_to_simple_view()
@@ -774,6 +776,8 @@ def factory(
774776
view.pop("key")
775777
if cert is None:
776778
view.pop("cert")
779+
if ca_cert is None:
780+
view.pop("ca_cert")
777781
view.pop("expiration_threshold")
778782
view.pop("overcome_threshold")
779783

0 commit comments

Comments
 (0)