Skip to content

Commit 355a703

Browse files
authored
Merge pull request #612 from element-hq/bbz/improve-integration-test-tls-certificate-handling
Improve integration test TLS certificate handling
2 parents 9af63a3 + 75b9da4 commit 355a703

7 files changed

Lines changed: 66 additions & 65 deletions

File tree

newsfragments/612.internal.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
CI: improve testing of TLS certificates with intermediates.

tests/integration/artifacts/certs.py

Lines changed: 29 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from cryptography.hazmat.primitives import hashes, serialization
1818
from cryptography.hazmat.primitives.asymmetric import rsa
1919
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
20-
from cryptography.hazmat.primitives.serialization import load_pem_private_key, pkcs12
20+
from cryptography.hazmat.primitives.serialization import load_pem_private_key
2121
from cryptography.x509 import Certificate
2222
from cryptography.x509.oid import NameOID
2323
from platformdirs import user_cache_dir
@@ -29,25 +29,20 @@ class CertKey:
2929
cert: Certificate
3030
key: RSAPrivateKey
3131

32-
def cert_bundle_as_pfx(self, password: bytes | None = None) -> bytes:
33-
if password is None:
34-
password = b""
35-
36-
return pkcs12.serialize_key_and_certificates(
37-
name=b"certificate",
38-
key=self.key,
39-
cert=self.cert,
40-
cas=None,
41-
encryption_algorithm=serialization.BestAvailableEncryption(password)
42-
if password
43-
else serialization.NoEncryption(),
44-
)
32+
def get_root_ca(self) -> CertKey:
33+
if self.ca is None:
34+
return self
35+
return self.ca.get_root_ca()
4536

4637
def cert_bundle_as_pem(self):
4738
bundle = []
4839
bundle.append(self.cert.public_bytes(encoding=serialization.Encoding.PEM).decode("utf-8"))
49-
if self.ca is not None:
50-
bundle.append(self.ca.cert_bundle_as_pem())
40+
ca = self.ca
41+
while ca is not None:
42+
# We only append this CA cert if it isn't the root
43+
if ca.ca is not None:
44+
bundle.append(self.ca.cert_as_pem())
45+
ca = ca.ca
5146
return "".join(bundle)
5247

5348
def cert_as_pem(self):
@@ -76,11 +71,10 @@ def to_json_mapping(self) -> dict[str, Any]:
7671
}
7772

7873

79-
def get_ca(name, root_ca=None) -> CertKey:
74+
def get_ca(name, issuing_ca=None) -> CertKey:
8075
ca_filename = Path(user_cache_dir("pytest-ess", "element")) / Path(name.lower().replace(" ", "-"))
8176
cert_path = ca_filename.with_suffix(".crt")
8277
key_path = ca_filename.with_suffix(".key")
83-
bundle_path = (ca_filename.parent / (ca_filename.name + "-bundle")).with_suffix(".pem")
8478
if not ca_filename.parent.exists():
8579
os.makedirs(ca_filename.parent, exist_ok=True)
8680
certkey = None
@@ -93,19 +87,25 @@ def get_ca(name, root_ca=None) -> CertKey:
9387
with open(cert_path, "rb") as pem_in:
9488
cert = x509.load_pem_x509_certificate(pem_in.read(), default_backend())
9589
if cert.not_valid_after_utc > pytz.UTC.localize(datetime.datetime.now()):
96-
certkey = CertKey(ca=root_ca, cert=cert, key=private_key)
90+
certkey = CertKey(ca=issuing_ca, cert=cert, key=private_key)
91+
9792
if not certkey:
98-
certkey = generate_ca(name, root_ca)
93+
certkey = generate_ca(name, issuing_ca)
9994
with open(key_path, "wb") as pem_out:
10095
pem_out.write(certkey.key_as_pem().encode("utf-8"))
10196
with open(cert_path, "wb") as pem_out:
10297
pem_out.write(certkey.cert_as_pem().encode("utf-8"))
103-
with open(bundle_path, "wb") as pem_out:
104-
pem_out.write(certkey.cert_bundle_as_pem().encode("utf-8"))
98+
99+
# Remove unused bundle - given we should only need to trust the root CA, that the tests will construct the
100+
# bundle appropriate for ingresses, and that a bundle of CA certs wasn't super useful this file was unneeded
101+
bundle_path = (ca_filename.parent / (ca_filename.name + "-bundle")).with_suffix(".pem")
102+
if bundle_path.exists():
103+
bundle_path.unlink()
104+
105105
return certkey
106106

107107

108-
def generate_ca(name, root_ca=None) -> CertKey:
108+
def generate_ca(name, issuing_ca=None) -> CertKey:
109109
two_days = datetime.timedelta(2, 0, 0)
110110
three_months = datetime.timedelta(90, 0, 0)
111111
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048, backend=default_backend())
@@ -120,8 +120,8 @@ def generate_ca(name, root_ca=None) -> CertKey:
120120
]
121121
)
122122
)
123-
if root_ca:
124-
builder = builder.issuer_name(root_ca.cert.subject)
123+
if issuing_ca:
124+
builder = builder.issuer_name(issuing_ca.cert.subject)
125125
else:
126126
builder = builder.issuer_name(
127127
x509.Name(
@@ -145,12 +145,12 @@ def generate_ca(name, root_ca=None) -> CertKey:
145145
critical=True,
146146
)
147147
builder = builder.add_extension(x509.SubjectKeyIdentifier.from_public_key(public_key), critical=False)
148-
if root_ca:
148+
if issuing_ca:
149149
builder = builder.add_extension(
150-
x509.AuthorityKeyIdentifier.from_issuer_public_key(root_ca.cert.public_key()), critical=False
150+
x509.AuthorityKeyIdentifier.from_issuer_public_key(issuing_ca.cert.public_key()), critical=False
151151
)
152-
certificate = builder.sign(root_ca.key, hashes.SHA256(), default_backend())
153-
ca = CertKey(ca=root_ca, cert=certificate, key=private_key)
152+
certificate = builder.sign(issuing_ca.key, hashes.SHA256(), default_backend())
153+
ca = CertKey(ca=issuing_ca, cert=certificate, key=private_key)
154154
else:
155155
builder = builder.add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(public_key), critical=False)
156156
certificate = builder.sign(private_key, hashes.SHA256(), default_backend())

tests/integration/fixtures/__init__.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
#
33
# SPDX-License-Identifier: AGPL-3.0-only
44

5-
from .ca import ca, ssl_context
5+
from .ca import delegated_ca, root_ca, ssl_context
66
from .cluster import cluster, ess_namespace, helm_client, ingress, kube_client, prometheus_operator_crds, registry
77
from .data import ESSData, generated_data
88
from .helm import helm_prerequisites, ingress_ready, matrix_stack, secrets_generated
@@ -11,20 +11,21 @@
1111

1212
__all__ = [
1313
"build_matrix_tools",
14-
"ca",
1514
"cluster",
15+
"delegated_ca",
1616
"ess_namespace",
1717
"ESSData",
1818
"generated_data",
1919
"helm_client",
2020
"helm_prerequisites",
21-
"ingress",
2221
"ingress_ready",
22+
"ingress",
2323
"kube_client",
2424
"loaded_matrix_tools",
2525
"matrix_stack",
2626
"prometheus_operator_crds",
2727
"registry",
28+
"root_ca",
2829
"secrets_generated",
2930
"ssl_context",
3031
"users",

tests/integration/fixtures/ca.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2024 New Vector Ltd
1+
# Copyright 2024-2025 New Vector Ltd
22
#
33
# SPDX-License-Identifier: AGPL-3.0-only
44

@@ -9,15 +9,18 @@
99
from ..artifacts import get_ca
1010

1111

12-
@pytest.fixture(autouse=True, scope="session")
13-
async def ca():
14-
root_ca = get_ca("ESS CA")
15-
delegated_ca = get_ca("ESS CA Delegated", root_ca)
16-
return delegated_ca
12+
@pytest.fixture(scope="session")
13+
async def root_ca():
14+
return get_ca("ESS CA")
15+
16+
17+
@pytest.fixture(scope="session")
18+
async def delegated_ca(root_ca):
19+
return get_ca("ESS CA Delegated", root_ca)
1720

1821

1922
@pytest.fixture(scope="session")
20-
async def ssl_context(ca):
23+
async def ssl_context(root_ca):
2124
context = ssl.create_default_context()
22-
context.load_verify_locations(cadata=ca.cert_bundle_as_pem())
25+
context.load_verify_locations(cadata=root_ca.cert_as_pem())
2326
return context

tests/integration/fixtures/data.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ def random_string(choice, size):
2424
@dataclass(frozen=True)
2525
class ESSData:
2626
secrets_random: str
27-
ca: CertKey
27+
# Just for persisting across sessions, shouldn't be directly accessed
28+
_root_ca: CertKey
2829

2930
# Only here because we need to refer to it, in the tests, after the Secret has been constructed
3031
mas_oidc_client_secret: str
@@ -46,26 +47,26 @@ def from_dict(cls, kv):
4647
return ESSData(
4748
secrets_random=kv["secrets_random"],
4849
mas_oidc_client_secret=kv["mas_oidc_client_secret"],
49-
ca=CertKey.from_dict(kv["ca"]),
50+
_root_ca=CertKey.from_dict(kv["ca"]),
5051
)
5152

5253
def to_json_mapping(self) -> dict:
5354
return {
5455
"secrets_random": self.secrets_random,
55-
"ca": self.ca.to_json_mapping(),
56+
"ca": self._root_ca.to_json_mapping(),
5657
"mas_oidc_client_secret": self.mas_oidc_client_secret,
5758
}
5859

5960

6061
@pytest.fixture(scope="session")
61-
async def generated_data(pytestconfig, ca):
62+
async def generated_data(pytestconfig, root_ca):
6263
serialized_data = pytestconfig.cache.get("ess-helm/generated-data", None)
6364
if serialized_data:
6465
data = ESSData.from_dict(serialized_data)
6566
else:
6667
data = ESSData(
6768
secrets_random=random_string(string.ascii_lowercase + string.digits, 8),
68-
ca=ca,
69+
_root_ca=root_ca,
6970
mas_oidc_client_secret=secrets.token_urlsafe(36),
7071
)
7172
pytestconfig.cache.set("ess-helm/generated-data", data.to_json_mapping())

tests/integration/fixtures/helm.py

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,19 @@
1515
from lightkube.resources.core_v1 import Namespace, Secret, Service
1616
from lightkube.resources.networking_v1 import Ingress
1717

18+
from ..artifacts.certs import CertKey, generate_cert
1819
from ..lib.helpers import kubernetes_docker_secret, kubernetes_tls_secret, wait_for_endpoint_ready
1920
from ..lib.utils import DockerAuth, docker_config_json, value_file_has
2021
from .data import ESSData
2122

2223

2324
@pytest.fixture(scope="session")
2425
async def helm_prerequisites(
25-
kube_client: AsyncClient, helm_client: pyhelm3.Client, ca, ess_namespace: Namespace, generated_data: ESSData
26+
kube_client: AsyncClient,
27+
helm_client: pyhelm3.Client,
28+
delegated_ca: CertKey,
29+
ess_namespace: Namespace,
30+
generated_data: ESSData,
2631
):
2732
resources = []
2833
setups: list[Awaitable] = []
@@ -50,9 +55,7 @@ async def helm_prerequisites(
5055
kubernetes_tls_secret(
5156
f"{generated_data.release_name}-matrix-rtc-tls",
5257
generated_data.ess_namespace,
53-
ca,
54-
[f"mrtc.{generated_data.server_name}"],
55-
bundled=True,
58+
generate_cert(delegated_ca, [f"mrtc.{generated_data.server_name}"]),
5659
)
5760
)
5861

@@ -61,9 +64,7 @@ async def helm_prerequisites(
6164
kubernetes_tls_secret(
6265
f"{generated_data.release_name}-element-web-tls",
6366
generated_data.ess_namespace,
64-
ca,
65-
[f"element.{generated_data.server_name}"],
66-
bundled=True,
67+
generate_cert(delegated_ca, [f"element.{generated_data.server_name}"]),
6768
)
6869
)
6970

@@ -76,9 +77,7 @@ async def helm_prerequisites(
7677
kubernetes_tls_secret(
7778
f"{generated_data.release_name}-mas-web-tls",
7879
generated_data.ess_namespace,
79-
ca,
80-
[f"mas.{generated_data.server_name}"],
81-
bundled=True,
80+
generate_cert(delegated_ca, [f"mas.{generated_data.server_name}"]),
8281
)
8382
)
8483
resources.append(
@@ -108,9 +107,7 @@ async def helm_prerequisites(
108107
kubernetes_tls_secret(
109108
f"{generated_data.release_name}-synapse-web-tls",
110109
generated_data.ess_namespace,
111-
ca,
112-
[f"synapse.{generated_data.server_name}"],
113-
bundled=True,
110+
generate_cert(delegated_ca, [f"synapse.{generated_data.server_name}"]),
114111
)
115112
)
116113
resources.append(
@@ -134,9 +131,7 @@ async def helm_prerequisites(
134131
kubernetes_tls_secret(
135132
f"{generated_data.release_name}-well-known-web-tls",
136133
generated_data.ess_namespace,
137-
ca,
138-
[generated_data.server_name],
139-
bundled=True,
134+
generate_cert(delegated_ca, [generated_data.server_name]),
140135
)
141136
)
142137

tests/integration/lib/helpers.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from lightkube.models.meta_v1 import ObjectMeta
1818
from lightkube.resources.core_v1 import ConfigMap, Endpoints, Namespace, Pod, Secret
1919

20-
from ..artifacts import CertKey, generate_cert
20+
from ..artifacts import CertKey
2121
from .utils import merge
2222

2323

@@ -34,14 +34,14 @@ def kubernetes_docker_secret(name: str, namespace: str, docker_config_json: str)
3434
return secret
3535

3636

37-
def kubernetes_tls_secret(name: str, namespace: str, ca: CertKey, dns_names: list[str], bundled=False) -> Secret:
38-
certificate = generate_cert(ca, dns_names)
37+
def kubernetes_tls_secret(name: str, namespace: str, certificate: CertKey) -> Secret:
3938
secret = Secret(
4039
type="kubernetes.io/tls",
4140
metadata=ObjectMeta(name=name, namespace=namespace, labels={"app.kubernetes.io/managed-by": "pytest"}),
4241
stringData={
43-
"tls.crt": certificate.cert_bundle_as_pem() if bundled else certificate.cert_as_pem(),
42+
"tls.crt": certificate.cert_bundle_as_pem(),
4443
"tls.key": certificate.key_as_pem(),
44+
"ca.crt": certificate.get_root_ca().cert_as_pem(),
4545
},
4646
)
4747
return secret

0 commit comments

Comments
 (0)