Merge ~r00ta/maas:MAASENG-2908-2-3.5 into maas:3.5
- Git
- lp:~r00ta/maas
- MAASENG-2908-2-3.5
- Merge into 3.5
Proposed by
Jacopo Rota
Status: | Merged |
---|---|
Approved by: | Jacopo Rota |
Approved revision: | a8782120a90542c5c75090779f1c24b7df85688e |
Merge reported by: | MAAS Lander |
Merged at revision: | not available |
Proposed branch: | ~r00ta/maas:MAASENG-2908-2-3.5 |
Merge into: | maas:3.5 |
Diff against target: |
748 lines (+327/-73) 10 files modified
src/maasserver/rpc/tests/test_regionservice.py (+3/-2) src/maasserver/secrets.py (+1/-0) src/maasserver/start_up.py (+50/-22) src/maasserver/tests/test_start_up.py (+33/-4) src/maasserver/utils/certificates.py (+43/-9) src/maasserver/utils/tests/test_certificates.py (+19/-6) src/provisioningserver/certificates.py (+90/-20) src/provisioningserver/rpc/clusterservice.py (+2/-0) src/provisioningserver/rpc/tests/test_clusterservice.py (+7/-1) src/provisioningserver/tests/test_certificates.py (+79/-9) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
MAAS Lander | Approve | ||
Jacopo Rota | Approve | ||
Review via email: mp+463661@code.launchpad.net |
Commit message
fix: generate a root maas CA certificate to sign the cluster certificate to be used by temporal mTLS.
(cherry-pick from 34d4731ac18cd72
Description of the change
To post a comment you must log in.
Revision history for this message
MAAS Lander (maas-lander) wrote : | # |
UNIT TESTS
-b MAASENG-2908-2-3.5 lp:~r00ta/maas/+git/maas into -b 3.5 lp:~maas-committers/maas
STATUS: SUCCESS
COMMIT: a8782120a90542c
review:
Approve
Update scan failed
At least one of the branches involved have failed to scan. You can manually schedule a rescan if required.
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | diff --git a/src/maasserver/rpc/tests/test_regionservice.py b/src/maasserver/rpc/tests/test_regionservice.py |
2 | index 926940a..901f78a 100644 |
3 | --- a/src/maasserver/rpc/tests/test_regionservice.py |
4 | +++ b/src/maasserver/rpc/tests/test_regionservice.py |
5 | @@ -281,7 +281,8 @@ class TestRegionServer(MAASTransactionServerTestCase): |
6 | def setup_cluster_certificates(): |
7 | secret_manger = SecretManager() |
8 | secret_manger.set_composite_secret( |
9 | - "cluster-certificate", {"key": "key", "cert": "cert"} |
10 | + "cluster-certificate", |
11 | + {"key": "key", "cert": "cert", "cacerts": "cacerts"}, |
12 | ) |
13 | |
14 | yield deferToDatabase(transactional(setup_cluster_certificates)) |
15 | @@ -367,7 +368,7 @@ class TestRegionServer(MAASTransactionServerTestCase): |
16 | json.loads( |
17 | fernet_decrypt_psk(response["encrypted_cluster_certificate"]) |
18 | ), |
19 | - {"key": "key", "cert": "cert"}, |
20 | + {"key": "key", "cert": "cert", "cacerts": "cacerts"}, |
21 | ) |
22 | |
23 | @wait_for_reactor |
24 | diff --git a/src/maasserver/secrets.py b/src/maasserver/secrets.py |
25 | index 10334ec..1c7edd0 100644 |
26 | --- a/src/maasserver/secrets.py |
27 | +++ b/src/maasserver/secrets.py |
28 | @@ -59,6 +59,7 @@ GLOBAL_SECRETS = frozenset( |
29 | "macaroon-key", |
30 | "omapi-key", |
31 | "rpc-shared", |
32 | + "maas-ca-certificate", |
33 | "cluster-certificate", |
34 | "tls", |
35 | "vcenter-password", |
36 | diff --git a/src/maasserver/start_up.py b/src/maasserver/start_up.py |
37 | index 41f3a0b..f2a1808 100644 |
38 | --- a/src/maasserver/start_up.py |
39 | +++ b/src/maasserver/start_up.py |
40 | @@ -27,7 +27,10 @@ from maasserver.models.config import ensure_uuid_in_config |
41 | from maasserver.models.domain import dns_kms_setting_changed |
42 | from maasserver.secrets import SecretManager, SecretNotFound |
43 | from maasserver.utils import synchronised |
44 | -from maasserver.utils.certificates import generate_self_signed_v3_certificate |
45 | +from maasserver.utils.certificates import ( |
46 | + generate_ca_certificate, |
47 | + generate_signed_certificate, |
48 | +) |
49 | from maasserver.utils.orm import ( |
50 | get_psycopg2_exception, |
51 | transactional, |
52 | @@ -128,34 +131,59 @@ def _cleanup_expired_discovered_ip_addresses() -> None: |
53 | ).delete() |
54 | |
55 | |
56 | +def _get_certificate_from_database( |
57 | + secret_manager: SecretManager, secret_name: str |
58 | +) -> Certificate | None: |
59 | + try: |
60 | + raw_certificate = secret_manager.get_composite_secret(secret_name) |
61 | + return Certificate.from_pem( |
62 | + raw_certificate["key"], |
63 | + raw_certificate["cert"], |
64 | + ca_certs_material=raw_certificate.get("cacerts", ""), |
65 | + ) |
66 | + except SecretNotFound: |
67 | + return None |
68 | + |
69 | + |
70 | def _create_cluster_certificate_if_necessary( |
71 | client: VaultClient | None = None, |
72 | ) -> None: |
73 | # Use the vault if configured. |
74 | secret_manager = SecretManager(client) |
75 | - # The PK/certificate are not on the disk yet |
76 | + |
77 | + # The PK/certificate for the maas CA are not in the db yet |
78 | + maas_ca = _get_certificate_from_database( |
79 | + secret_manager, "maas-ca-certificate" |
80 | + ) |
81 | + if not maas_ca: |
82 | + maas_ca = generate_ca_certificate("maas-ca") |
83 | + secrets = { |
84 | + "key": maas_ca.private_key_pem(), |
85 | + "cert": maas_ca.certificate_pem(), |
86 | + } |
87 | + secret_manager.set_composite_secret("maas-ca-certificate", secrets) |
88 | + |
89 | + # The PK/certificate for the cluster are not in the db yet |
90 | + cluster_certificate = _get_certificate_from_database( |
91 | + secret_manager, "cluster-certificate" |
92 | + ) |
93 | + if not cluster_certificate: |
94 | + cluster_certificate = generate_signed_certificate( |
95 | + maas_ca, "maas-cluster", b"DNS:maas" |
96 | + ) |
97 | + secrets = { |
98 | + "key": cluster_certificate.private_key_pem(), |
99 | + "cert": cluster_certificate.certificate_pem(), |
100 | + "cacerts": cluster_certificate.ca_certificates_pem(), |
101 | + } |
102 | + secret_manager.set_composite_secret("cluster-certificate", secrets) |
103 | + |
104 | + # If the certificates are not on the disk yet store them. |
105 | if not get_maas_cluster_cert_paths(): |
106 | - # Create the PK/certificate if they don't exist yet. Store the files on the disk as well. |
107 | - try: |
108 | - raw_certificate = secret_manager.get_composite_secret( |
109 | - "cluster-certificate" |
110 | - ) |
111 | - certificate = Certificate.from_pem( |
112 | - raw_certificate["key"], |
113 | - raw_certificate["cert"], |
114 | - ) |
115 | - except SecretNotFound: |
116 | - # If there are no PK/certificate at all, generate them. |
117 | - certificate = generate_self_signed_v3_certificate("maas", b"DNS:*") |
118 | - secrets = { |
119 | - "key": certificate.private_key_pem(), |
120 | - "cert": certificate.certificate_pem(), |
121 | - } |
122 | - secret_manager.set_composite_secret("cluster-certificate", secrets) |
123 | - # Store the PK and the certificate to the disk |
124 | store_maas_cluster_cert_tuple( |
125 | - private_key=certificate.private_key_pem().encode(), |
126 | - certificate=certificate.certificate_pem().encode(), |
127 | + private_key=cluster_certificate.private_key_pem().encode(), |
128 | + certificate=cluster_certificate.certificate_pem().encode(), |
129 | + cacerts=cluster_certificate.ca_certificates_pem().encode(), |
130 | ) |
131 | |
132 | |
133 | diff --git a/src/maasserver/tests/test_start_up.py b/src/maasserver/tests/test_start_up.py |
134 | index eb33ca2..125b848 100644 |
135 | --- a/src/maasserver/tests/test_start_up.py |
136 | +++ b/src/maasserver/tests/test_start_up.py |
137 | @@ -28,7 +28,10 @@ from maasserver.testing.testcase import ( |
138 | MAASTransactionServerTestCase, |
139 | ) |
140 | from maasserver.testing.vault import FakeVaultClient |
141 | -from maasserver.utils.certificates import generate_self_signed_v3_certificate |
142 | +from maasserver.utils.certificates import ( |
143 | + generate_ca_certificate, |
144 | + generate_signed_certificate, |
145 | +) |
146 | from maasserver.utils.orm import post_commit_hooks |
147 | from maasserver.vault import UnknownSecretPath, VaultError |
148 | from maastesting import get_testing_timeout |
149 | @@ -524,11 +527,28 @@ class TestCreateClusterCertificate(MAASServerTestCase): |
150 | self.assertRaises( |
151 | SecretNotFound, |
152 | secret_manager.get_composite_secret, |
153 | + "maas-ca-certificate", |
154 | + ) |
155 | + self.assertRaises( |
156 | + SecretNotFound, |
157 | + secret_manager.get_composite_secret, |
158 | "cluster-certificate", |
159 | ) |
160 | |
161 | _create_cluster_certificate_if_necessary() |
162 | self.assertIsNotNone(get_maas_cluster_cert_paths()) |
163 | + |
164 | + maasca_secret = secret_manager.get_composite_secret( |
165 | + "maas-ca-certificate" |
166 | + ) |
167 | + maasca = Certificate.from_pem( |
168 | + maasca_secret["key"], |
169 | + maasca_secret["cert"], |
170 | + ) |
171 | + |
172 | + self.assertIsNotNone(maasca.private_key_pem()) |
173 | + self.assertIsNotNone(maasca.certificate_pem()) |
174 | + |
175 | certificate_secret = secret_manager.get_composite_secret( |
176 | "cluster-certificate" |
177 | ) |
178 | @@ -539,6 +559,7 @@ class TestCreateClusterCertificate(MAASServerTestCase): |
179 | |
180 | self.assertIsNotNone(certificate.private_key_pem()) |
181 | self.assertIsNotNone(certificate.certificate_pem()) |
182 | + self.assertIsNotNone(certificate.ca_certificates_pem()) |
183 | |
184 | def test_fetch_certificates_from_db(self): |
185 | # No certificates on the disk |
186 | @@ -546,10 +567,18 @@ class TestCreateClusterCertificate(MAASServerTestCase): |
187 | |
188 | # store the certificates on the database |
189 | secret_manager = SecretManager() |
190 | - certificate = generate_self_signed_v3_certificate("maas") |
191 | + maasca = generate_ca_certificate("maasca") |
192 | + secrets = { |
193 | + "key": maasca.private_key_pem(), |
194 | + "cert": maasca.certificate_pem(), |
195 | + } |
196 | + secret_manager.set_composite_secret("maas-ca-certificate", secrets) |
197 | + |
198 | + cluster_certificate = generate_signed_certificate(maasca, "cluster") |
199 | secrets = { |
200 | - "key": certificate.private_key_pem(), |
201 | - "cert": certificate.certificate_pem(), |
202 | + "key": cluster_certificate.private_key_pem(), |
203 | + "cert": cluster_certificate.certificate_pem(), |
204 | + "cacerts": cluster_certificate.ca_certificates_pem(), |
205 | } |
206 | secret_manager.set_composite_secret("cluster-certificate", secrets) |
207 | |
208 | diff --git a/src/maasserver/utils/certificates.py b/src/maasserver/utils/certificates.py |
209 | index 9eea26b..f63657f 100644 |
210 | --- a/src/maasserver/utils/certificates.py |
211 | +++ b/src/maasserver/utils/certificates.py |
212 | @@ -1,5 +1,5 @@ |
213 | from maasserver.models import Config |
214 | -from provisioningserver.certificates import Certificate |
215 | +from provisioningserver.certificates import Certificate, CertificateRequest |
216 | from provisioningserver.utils.env import MAAS_UUID |
217 | |
218 | |
219 | @@ -38,24 +38,58 @@ def generate_certificate(cn) -> Certificate: |
220 | ) |
221 | |
222 | |
223 | -def generate_self_signed_v3_certificate( |
224 | - cn: str, subject_alternative_name: bytes | None = None |
225 | +def generate_ca_certificate(cn: str) -> Certificate: |
226 | + """ |
227 | + Generate an X509 MAAS CA certificate with an RSA private key. |
228 | + |
229 | + Set Organization (O) and Organizational Unit (OU) fields to identify |
230 | + that a certificate was created from this MAAS deployment. |
231 | + |
232 | + Parameters: |
233 | + cn (str): Common Name (CN) for the subject of the certificate. |
234 | + |
235 | + Returns: |
236 | + Certificate: The generated X509 MAAS CA certificate with an RSA private key. |
237 | + |
238 | + Raises: |
239 | + AssertionError: If the MAAS_UUID is not configured. |
240 | + """ |
241 | + maas_uuid = MAAS_UUID.get() |
242 | + assert maas_uuid is not None, "MAAS_UUID not configured, ensure it is set." |
243 | + return Certificate.generate_ca_certificate( |
244 | + cn, |
245 | + organization_name="MAAS", |
246 | + organizational_unit_name=maas_uuid, |
247 | + ) |
248 | + |
249 | + |
250 | +def generate_signed_certificate( |
251 | + ca: Certificate, cn: str, subject_alternative_name: bytes | None = None |
252 | ) -> Certificate: |
253 | - """Generate an X509 V3 self signed certificate with an RSA private key. |
254 | + """ |
255 | + Generate an X509 V3 certificate with an RSA private key signed with the root CA certificate provided. |
256 | |
257 | - Set O and OU so that we can identify that a certificate was |
258 | - created from this MAAS deployment. |
259 | + Parameters: |
260 | + ca (Certificate): The Certificate Authority (CA) certificate used to sign the generated certificate. |
261 | + cn (str): Common Name (CN) for the subject of the certificate. |
262 | + subject_alternative_name (bytes | None): Subject Alternative Name (SAN) for the certificate, |
263 | + can be None if not needed. |
264 | + |
265 | + Returns: |
266 | + Certificate: The generated X509 V3 certificate signed with the root CA's private key. |
267 | + |
268 | + Raises: |
269 | + AssertionError: If the MAAS_UUID is not configured. |
270 | """ |
271 | - # Set O and OU so that we can identify that a certificate was |
272 | - # created from this MAAS deployment. |
273 | maas_uuid = MAAS_UUID.get() |
274 | assert maas_uuid is not None, "MAAS_UUID not configured, ensure it is set." |
275 | - return Certificate.generate_self_signed_v3( |
276 | + certificate_request = CertificateRequest.generate( |
277 | cn, |
278 | organization_name="MAAS", |
279 | organizational_unit_name=maas_uuid, |
280 | subject_alternative_name=subject_alternative_name, |
281 | ) |
282 | + return ca.sign_certificate_request(certificate_request) |
283 | |
284 | |
285 | def certificate_generated_by_this_maas(certificate: Certificate): |
286 | diff --git a/src/maasserver/utils/tests/test_certificates.py b/src/maasserver/utils/tests/test_certificates.py |
287 | index 1514bde..d84113d 100644 |
288 | --- a/src/maasserver/utils/tests/test_certificates.py |
289 | +++ b/src/maasserver/utils/tests/test_certificates.py |
290 | @@ -1,3 +1,4 @@ |
291 | +from unittest.mock import Mock |
292 | from uuid import uuid4 |
293 | |
294 | from OpenSSL import crypto |
295 | @@ -8,8 +9,8 @@ from maasserver.testing.testcase import MAASServerTestCase |
296 | import maasserver.utils.certificates as certificates |
297 | from maasserver.utils.certificates import ( |
298 | certificate_generated_by_this_maas, |
299 | + generate_ca_certificate, |
300 | generate_certificate, |
301 | - generate_self_signed_v3_certificate, |
302 | get_maas_client_cn, |
303 | ) |
304 | from provisioningserver.certificates import Certificate |
305 | @@ -48,20 +49,32 @@ class TestGenerateCertificate(MAASServerTestCase): |
306 | ) |
307 | |
308 | |
309 | -class TestGenerateSelfSignedCertificate(MAASServerTestCase): |
310 | - def test_generate_self_signed_certificate(self): |
311 | +class TestGenerateCACertificate(MAASServerTestCase): |
312 | + def test_generate_ca_certificate(self): |
313 | mock_cert = self.patch_autospec(certificates, "Certificate") |
314 | maas_uuid = str(uuid4()) |
315 | self.useFixture(MAASUUIDFixture(maas_uuid)) |
316 | - generate_self_signed_v3_certificate("maas", b"DNS:*") |
317 | - mock_cert.generate_self_signed_v3.assert_called_once_with( |
318 | + generate_ca_certificate("maas") |
319 | + mock_cert.generate_ca_certificate.assert_called_once_with( |
320 | "maas", |
321 | organization_name="MAAS", |
322 | organizational_unit_name=maas_uuid, |
323 | - subject_alternative_name=b"DNS:*", |
324 | ) |
325 | |
326 | |
327 | +class TestGenerateSignedCertificate(MAASServerTestCase): |
328 | + def test_generate_ca_certificate(self): |
329 | + mock_cert_request = self.patch_autospec( |
330 | + certificates, "CertificateRequest" |
331 | + ) |
332 | + maas_uuid = str(uuid4()) |
333 | + self.useFixture(MAASUUIDFixture(maas_uuid)) |
334 | + ca = Mock() |
335 | + certificates.generate_signed_certificate(ca, "maas") |
336 | + mock_cert_request.generate.assert_called_once() |
337 | + ca.sign_certificate_request.assert_called_once() |
338 | + |
339 | + |
340 | class TestCertificateGeneratedByThisMAAS(MAASServerTestCase): |
341 | def setUp(self): |
342 | super().setUp() |
343 | diff --git a/src/provisioningserver/certificates.py b/src/provisioningserver/certificates.py |
344 | index f7bdf7c..ddd6559 100644 |
345 | --- a/src/provisioningserver/certificates.py |
346 | +++ b/src/provisioningserver/certificates.py |
347 | @@ -31,6 +31,44 @@ class CertificateError(Exception): |
348 | """Error handling certificates and keys.""" |
349 | |
350 | |
351 | +class CertificateRequest(NamedTuple): |
352 | + key: crypto.PKey |
353 | + csr: crypto.X509Req |
354 | + |
355 | + @classmethod |
356 | + def generate( |
357 | + cls, |
358 | + cn: str, |
359 | + organization_name: Optional[str] = None, |
360 | + organizational_unit_name: Optional[str] = None, |
361 | + key_bits: int = 4096, |
362 | + subject_alternative_name: bytes | None = None, |
363 | + ): |
364 | + key = crypto.PKey() |
365 | + key.generate_key(crypto.TYPE_RSA, key_bits) |
366 | + |
367 | + csr = crypto.X509Req() |
368 | + csr.get_subject().CN = cn[:64] |
369 | + if organization_name: |
370 | + csr.get_subject().organizationName = organization_name[:64] |
371 | + if organizational_unit_name: |
372 | + csr.get_subject().organizationalUnitName = ( |
373 | + organizational_unit_name[:64] |
374 | + ) |
375 | + csr.set_pubkey(key) |
376 | + |
377 | + if subject_alternative_name: |
378 | + csr.add_extensions( |
379 | + [ |
380 | + crypto.X509Extension( |
381 | + b"subjectAltName", False, subject_alternative_name |
382 | + ) |
383 | + ] |
384 | + ) |
385 | + csr.sign(key, "sha512") |
386 | + return cls(key, csr) |
387 | + |
388 | + |
389 | class Certificate(NamedTuple): |
390 | """A self-signed X509 certificate with an associated key.""" |
391 | |
392 | @@ -72,12 +110,10 @@ class Certificate(NamedTuple): |
393 | cls, |
394 | key: crypto.PKey, |
395 | version: crypto.x509.Version, |
396 | - cn: str, |
397 | validity: timedelta, |
398 | ) -> crypto.X509: |
399 | cert = crypto.X509() |
400 | cert.set_version(version.value) |
401 | - cert.get_subject().CN = cn[:64] |
402 | cert.set_serial_number(random.randint(0, (1 << 128) - 1)) |
403 | cert.gmtime_adj_notBefore(0) |
404 | cert.gmtime_adj_notAfter(int(validity.total_seconds())) |
405 | @@ -106,8 +142,9 @@ class Certificate(NamedTuple): |
406 | key.generate_key(crypto.TYPE_RSA, key_bits) |
407 | |
408 | cert = cls._build_base_certificate( |
409 | - key, crypto.x509.Version.v1, cn, validity |
410 | + key, crypto.x509.Version.v1, validity |
411 | ) |
412 | + cert.get_subject().CN = cn[:64] |
413 | if organization_name: |
414 | cert.get_issuer().organizationName = organization_name[:64] |
415 | if organizational_unit_name: |
416 | @@ -119,26 +156,26 @@ class Certificate(NamedTuple): |
417 | return cls(key, cert, ()) |
418 | |
419 | @classmethod |
420 | - def generate_self_signed_v3( |
421 | + def generate_ca_certificate( |
422 | cls, |
423 | cn: str, |
424 | organization_name: Optional[str] = None, |
425 | organizational_unit_name: Optional[str] = None, |
426 | key_bits: int = 4096, |
427 | validity: timedelta = timedelta(days=3650), |
428 | - subject_alternative_name: bytes | None = None, |
429 | ) -> "Certificate": |
430 | - """Low-level method for generating a self-signed certificate X509 v3 certificate. |
431 | + """Low-level method for generating a root X509 certificate. |
432 | |
433 | This should only be used in test and in cases where you don't have |
434 | - access to the database. Use maasserver.utils.certificate.generate_self_signed_v3_certificate() instead. |
435 | + access to the database. Use maasserver.utils.certificate.generate_ca_certificate() instead. |
436 | """ |
437 | key = crypto.PKey() |
438 | key.generate_key(crypto.TYPE_RSA, key_bits) |
439 | |
440 | cert = cls._build_base_certificate( |
441 | - key, crypto.x509.Version.v3, cn, validity |
442 | + key, crypto.x509.Version.v3, validity |
443 | ) |
444 | + cert.get_subject().CN = cn[:64] |
445 | if organization_name: |
446 | cert.get_issuer().organizationName = organization_name[:64] |
447 | if organizational_unit_name: |
448 | @@ -151,17 +188,38 @@ class Certificate(NamedTuple): |
449 | cert.add_extensions( |
450 | [crypto.X509Extension(b"basicConstraints", True, b"CA:TRUE")] |
451 | ) |
452 | - if subject_alternative_name: |
453 | - cert.add_extensions( |
454 | - [ |
455 | - crypto.X509Extension( |
456 | - b"subjectAltName", False, subject_alternative_name |
457 | - ) |
458 | - ] |
459 | - ) |
460 | cert.sign(key, "sha512") |
461 | return cls(key, cert, ()) |
462 | |
463 | + def sign_certificate_request( |
464 | + self, |
465 | + certificate_request: CertificateRequest, |
466 | + validity: timedelta = timedelta(days=3650), |
467 | + ) -> "Certificate": |
468 | + """ |
469 | + Sign a certificate request with the CA's private key. |
470 | + |
471 | + This method signs the provided certificate request with the Certificate Authority (CA)'s |
472 | + private key and returns the signed certificate. |
473 | + |
474 | + Parameters: |
475 | + certificate_request (CertificateRequest): The certificate request to sign. |
476 | + validity (timedelta): The validity period for the signed certificate. Default is 10 years. |
477 | + |
478 | + Returns: |
479 | + Certificate: The signed certificate. |
480 | + """ |
481 | + cert = Certificate._build_base_certificate( |
482 | + certificate_request.key, crypto.x509.Version.v3, validity |
483 | + ) |
484 | + cert.set_issuer(self.cert.get_subject()) |
485 | + cert.set_subject(certificate_request.csr.get_subject()) |
486 | + cert.add_extensions(certificate_request.csr.get_extensions()) |
487 | + cert.sign(self.key, "sha512") |
488 | + return Certificate( |
489 | + key=certificate_request.key, cert=cert, ca_certs=(self.cert,) |
490 | + ) |
491 | + |
492 | def cn(self) -> str: |
493 | """Return the certificate CN.""" |
494 | return self.cert.get_subject().CN |
495 | @@ -256,19 +314,24 @@ def _get_cluster_certificates_path() -> Path: |
496 | return Path(maas_root) / "certificates" |
497 | |
498 | |
499 | -def get_maas_cluster_cert_paths() -> tuple[str, str] | None: |
500 | +def get_maas_cluster_cert_paths() -> tuple[str, str, str] | None: |
501 | """Return a 2-tuple with certificate and private key paths for the cluster certificates.""" |
502 | |
503 | cert_dir = _get_cluster_certificates_path() |
504 | private_key = cert_dir / "cluster.key" |
505 | certificate = cert_dir / "cluster.pem" |
506 | - if not private_key.exists() or not certificate.exists(): |
507 | + cacerts = cert_dir / "cacerts.pem" |
508 | + if ( |
509 | + not private_key.exists() |
510 | + or not certificate.exists() |
511 | + or not cacerts.exists() |
512 | + ): |
513 | return None |
514 | - return str(certificate), str(private_key) |
515 | + return str(certificate), str(private_key), str(cacerts) |
516 | |
517 | |
518 | def store_maas_cluster_cert_tuple( |
519 | - private_key: bytes, certificate: bytes |
520 | + private_key: bytes, certificate: bytes, cacerts: bytes |
521 | ) -> None: |
522 | """ |
523 | Stores the private key and the certificate on the disk. |
524 | @@ -289,6 +352,13 @@ def store_maas_cluster_cert_tuple( |
525 | mode=0o644, |
526 | ) |
527 | |
528 | + atomic_write( |
529 | + cacerts, |
530 | + cert_dir / "cacerts.pem", |
531 | + overwrite=True, |
532 | + mode=0o644, |
533 | + ) |
534 | + |
535 | |
536 | def get_maas_cert_tuple(): |
537 | """Return a 2-tuple with certificate and private key paths. |
538 | diff --git a/src/provisioningserver/rpc/clusterservice.py b/src/provisioningserver/rpc/clusterservice.py |
539 | index ba8970b..35f8d9a 100644 |
540 | --- a/src/provisioningserver/rpc/clusterservice.py |
541 | +++ b/src/provisioningserver/rpc/clusterservice.py |
542 | @@ -976,10 +976,12 @@ class ClusterClient(Cluster): |
543 | certificate = Certificate.from_pem( |
544 | decoded_secret["key"], |
545 | decoded_secret["cert"], |
546 | + ca_certs_material=decoded_secret["cacerts"], |
547 | ) |
548 | store_maas_cluster_cert_tuple( |
549 | private_key=certificate.private_key_pem().encode(), |
550 | certificate=certificate.certificate_pem().encode(), |
551 | + cacerts=certificate.ca_certificates_pem().encode(), |
552 | ) |
553 | |
554 | # If the region supports beacons, full registration of rack |
555 | diff --git a/src/provisioningserver/rpc/tests/test_clusterservice.py b/src/provisioningserver/rpc/tests/test_clusterservice.py |
556 | index d95a023..08d09b4 100644 |
557 | --- a/src/provisioningserver/rpc/tests/test_clusterservice.py |
558 | +++ b/src/provisioningserver/rpc/tests/test_clusterservice.py |
559 | @@ -40,6 +40,7 @@ from maastesting.twisted import ( |
560 | from provisioningserver import concurrency |
561 | from provisioningserver.certificates import ( |
562 | Certificate, |
563 | + CertificateRequest, |
564 | get_maas_cluster_cert_paths, |
565 | ) |
566 | from provisioningserver.dhcp.testing.config import ( |
567 | @@ -1322,7 +1323,10 @@ class TestClusterClientClusterCertificatesAreStored(TestClusterClientBase): |
568 | |
569 | client = self.make_running_client() |
570 | |
571 | - certificate = Certificate.generate_self_signed_v3("maas") |
572 | + maasca = Certificate.generate_ca_certificate("maas") |
573 | + certificate_request = CertificateRequest.generate("request") |
574 | + certificate = maasca.sign_certificate_request(certificate_request) |
575 | + |
576 | callRemote = self.patch_autospec(client, "callRemote") |
577 | callRemote.side_effect = always_succeed_with( |
578 | { |
579 | @@ -1332,6 +1336,7 @@ class TestClusterClientClusterCertificatesAreStored(TestClusterClientBase): |
580 | { |
581 | "cert": certificate.certificate_pem(), |
582 | "key": certificate.private_key_pem(), |
583 | + "cacerts": certificate.ca_certificates_pem(), |
584 | } |
585 | ) |
586 | ), |
587 | @@ -1345,6 +1350,7 @@ class TestClusterClientClusterCertificatesAreStored(TestClusterClientBase): |
588 | ( |
589 | f"{certs_dir}/cluster.pem", |
590 | f"{certs_dir}/cluster.key", |
591 | + f"{certs_dir}/cacerts.pem", |
592 | ), |
593 | ) |
594 | |
595 | diff --git a/src/provisioningserver/tests/test_certificates.py b/src/provisioningserver/tests/test_certificates.py |
596 | index 348aa2d..4e12897 100644 |
597 | --- a/src/provisioningserver/tests/test_certificates.py |
598 | +++ b/src/provisioningserver/tests/test_certificates.py |
599 | @@ -11,6 +11,7 @@ from maastesting.testcase import MAASTestCase |
600 | from provisioningserver.certificates import ( |
601 | Certificate, |
602 | CertificateError, |
603 | + CertificateRequest, |
604 | get_maas_cert_tuple, |
605 | get_maas_cluster_cert_paths, |
606 | store_maas_cluster_cert_tuple, |
607 | @@ -18,6 +19,26 @@ from provisioningserver.certificates import ( |
608 | from provisioningserver.testing.certificates import get_sample_cert |
609 | |
610 | |
611 | +class TestCertificateRequest(MAASTestCase): |
612 | + def test_certificate_request(self): |
613 | + request = CertificateRequest.generate( |
614 | + "maas", "organization", "organization_unit", 2048, b"DNS:*" |
615 | + ) |
616 | + self.assertIsInstance(request.csr, crypto.X509Req) |
617 | + self.assertIsInstance(request.key, crypto.PKey) |
618 | + |
619 | + self.assertEqual(request.csr.get_subject().CN, "maas") |
620 | + self.assertEqual(request.csr.get_subject().O, "organization") |
621 | + self.assertEqual(request.csr.get_subject().OU, "organization_unit") |
622 | + |
623 | + self.assertEqual(len(request.csr.get_extensions()), 1) |
624 | + subject_alt_name_extension = request.csr.get_extensions()[0] |
625 | + self.assertEqual( |
626 | + subject_alt_name_extension.get_short_name(), b"subjectAltName" |
627 | + ) |
628 | + self.assertEqual(subject_alt_name_extension.get_critical(), False) |
629 | + |
630 | + |
631 | class TestCertificate(MAASTestCase): |
632 | def setUp(self): |
633 | super().setUp() |
634 | @@ -212,13 +233,12 @@ class TestCertificate(MAASTestCase): |
635 | cert.not_before(), |
636 | ) |
637 | |
638 | - def test_generate_self_signed_v3(self): |
639 | - cert = Certificate.generate_self_signed_v3( |
640 | + def test_generate_ca_certificate(self): |
641 | + cert = Certificate.generate_ca_certificate( |
642 | "maas", |
643 | organization_name="test", |
644 | organizational_unit_name="unit", |
645 | validity=timedelta(days=100), |
646 | - subject_alternative_name=b"DNS:*", |
647 | ) |
648 | |
649 | self.assertIsInstance(cert.cert, crypto.X509) |
650 | @@ -246,7 +266,7 @@ class TestCertificate(MAASTestCase): |
651 | self.assertEqual( |
652 | x509certificate.get_version(), crypto.x509.Version.v3.value |
653 | ) |
654 | - self.assertEqual(x509certificate.get_extension_count(), 2) |
655 | + self.assertEqual(x509certificate.get_extension_count(), 1) |
656 | |
657 | # Extensions are kept in order |
658 | basic_constraints_extension = x509certificate.get_extension(0) |
659 | @@ -255,11 +275,52 @@ class TestCertificate(MAASTestCase): |
660 | ) |
661 | self.assertEqual(basic_constraints_extension.get_critical(), True) |
662 | |
663 | - subject_alt_name_extension = x509certificate.get_extension(1) |
664 | + def test_sign_certificate(self): |
665 | + certificate_request = CertificateRequest.generate( |
666 | + "maas", "organization", "organization_unit", 2048, b"DNS:*" |
667 | + ) |
668 | + ca = Certificate.generate_ca_certificate( |
669 | + "maas", |
670 | + organization_name="test", |
671 | + organizational_unit_name="unit", |
672 | + validity=timedelta(days=100), |
673 | + ) |
674 | + |
675 | + signed_certificate = ca.sign_certificate_request(certificate_request) |
676 | + |
677 | + self.assertIsInstance(signed_certificate.cert, crypto.X509) |
678 | + self.assertIsInstance(signed_certificate.key, crypto.PKey) |
679 | + self.assertEqual(len(signed_certificate.ca_certs), 1) |
680 | + self.assertEqual(signed_certificate.cert.get_subject().CN, "maas") |
681 | + self.assertEqual(signed_certificate.key, certificate_request.key) |
682 | + self.assertLessEqual( |
683 | + datetime.utcnow() + timedelta(days=-1), |
684 | + signed_certificate.not_before(), |
685 | + ) |
686 | + self.assertGreater( |
687 | + signed_certificate.expiration(), |
688 | + signed_certificate.not_before(), |
689 | + ) |
690 | self.assertEqual( |
691 | - subject_alt_name_extension.get_short_name(), b"subjectAltName" |
692 | + len(certificate_request.csr.get_extensions()), |
693 | + signed_certificate.cert.get_extension_count(), |
694 | ) |
695 | - self.assertEqual(subject_alt_name_extension.get_critical(), False) |
696 | + self.assertEqual( |
697 | + certificate_request.csr.get_extensions()[0].get_short_name(), |
698 | + signed_certificate.cert.get_extension(0).get_short_name(), |
699 | + ) |
700 | + self.assertEqual( |
701 | + signed_certificate.cert.get_issuer(), ca.cert.get_subject() |
702 | + ) |
703 | + |
704 | + # check that the signed certificate is valid |
705 | + store = crypto.X509Store() |
706 | + store.add_cert(ca.cert) |
707 | + context = crypto.X509StoreContext(store, signed_certificate.cert) |
708 | + try: |
709 | + context.verify_certificate() |
710 | + except crypto.X509StoreContextError: |
711 | + self.fail("Failed to verify the signed certificate.") |
712 | |
713 | |
714 | class TestClusterCertificates(MAASTestCase): |
715 | @@ -279,11 +340,13 @@ class TestClusterCertificates(MAASTestCase): |
716 | certs_dir.mkdir(parents=True) |
717 | (certs_dir / "cluster.pem").touch() |
718 | (certs_dir / "cluster.key").touch() |
719 | + (certs_dir / "cacerts.pem").touch() |
720 | self.assertEqual( |
721 | get_maas_cluster_cert_paths(), |
722 | ( |
723 | f"{certs_dir}/cluster.pem", |
724 | f"{certs_dir}/cluster.key", |
725 | + f"{certs_dir}/cacerts.pem", |
726 | ), |
727 | ) |
728 | |
729 | @@ -291,10 +354,17 @@ class TestClusterCertificates(MAASTestCase): |
730 | certs_dir = self.tempdir / "certificates" |
731 | certs_dir.mkdir(parents=True) |
732 | self.useFixture(EnvironmentVariable("MAAS_ROOT", str(self.tempdir))) |
733 | - store_maas_cluster_cert_tuple(b"private_key", b"certificate") |
734 | - certificate_path, private_key_path = get_maas_cluster_cert_paths() |
735 | + store_maas_cluster_cert_tuple( |
736 | + b"private_key", b"certificate", b"cacerts" |
737 | + ) |
738 | + ( |
739 | + certificate_path, |
740 | + private_key_path, |
741 | + cacerts_path, |
742 | + ) = get_maas_cluster_cert_paths() |
743 | self.assertEqual(Path(certificate_path).lstat().st_mode & 0o777, 0o644) |
744 | self.assertEqual(Path(private_key_path).lstat().st_mode & 0o777, 0o600) |
745 | + self.assertEqual(Path(cacerts_path).lstat().st_mode & 0o777, 0o644) |
746 | |
747 | |
748 | class TestGetMAASCertTuple(MAASTestCase): |
self approving backport