Merge ~r00ta/maas:MAASENG-2908-2-3.5 into maas: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)
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 34d4731ac18cd72fe1f56300f5c7231fdc7773b9)

To post a comment you must log in.
Revision history for this message
Jacopo Rota (r00ta) wrote :

self approving backport

review: Approve
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: a8782120a90542c5c75090779f1c24b7df85688e

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
1diff --git a/src/maasserver/rpc/tests/test_regionservice.py b/src/maasserver/rpc/tests/test_regionservice.py
2index 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
24diff --git a/src/maasserver/secrets.py b/src/maasserver/secrets.py
25index 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",
36diff --git a/src/maasserver/start_up.py b/src/maasserver/start_up.py
37index 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
133diff --git a/src/maasserver/tests/test_start_up.py b/src/maasserver/tests/test_start_up.py
134index 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
208diff --git a/src/maasserver/utils/certificates.py b/src/maasserver/utils/certificates.py
209index 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):
286diff --git a/src/maasserver/utils/tests/test_certificates.py b/src/maasserver/utils/tests/test_certificates.py
287index 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()
343diff --git a/src/provisioningserver/certificates.py b/src/provisioningserver/certificates.py
344index 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.
538diff --git a/src/provisioningserver/rpc/clusterservice.py b/src/provisioningserver/rpc/clusterservice.py
539index 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
555diff --git a/src/provisioningserver/rpc/tests/test_clusterservice.py b/src/provisioningserver/rpc/tests/test_clusterservice.py
556index 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
595diff --git a/src/provisioningserver/tests/test_certificates.py b/src/provisioningserver/tests/test_certificates.py
596index 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):

Subscribers

People subscribed via source and target branches