Skip to content

Commit b12d143

Browse files
append server cert to chain (#109)
1 parent 5028ced commit b12d143

File tree

2 files changed

+109
-3
lines changed

2 files changed

+109
-3
lines changed

lib/charms/observability_libs/v1/cert_handler.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@
6767

6868
LIBID = "b5cd5cd580f3428fa5f59a8876dcbe6a"
6969
LIBAPI = 1
70-
LIBPATCH = 12
70+
LIBPATCH = 13
7171

7272
VAULT_SECRET_LABEL = "cert-handler-private-vault"
7373

@@ -584,9 +584,19 @@ def server_cert(self) -> Optional[str]:
584584

585585
@property
586586
def chain(self) -> Optional[str]:
587-
"""Return the ca chain bundled as a single PEM string."""
587+
"""Return the entire chain bundled as a single PEM string. This includes, if available, the certificate, intermediate CAs, and the root CA.
588+
589+
If the server certificate is not set in the chain by the provider, we'll add it
590+
to the top of the chain so that it could be used by a server.
591+
"""
588592
cert = self.get_cert()
589-
return cert.chain_as_pem() if cert else None
593+
if not cert:
594+
return None
595+
chain = cert.chain_as_pem()
596+
if cert.certificate not in chain:
597+
# add server cert to chain
598+
chain = cert.certificate + "\n\n" + chain
599+
return chain
590600

591601
def _on_certificate_expiring(
592602
self, event: Union[CertificateExpiringEvent, CertificateInvalidatedEvent]

tests/scenario/test_cert_handler/test_cert_handler_v1.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import datetime
2+
import json
13
import socket
24
import sys
35
from contextlib import contextmanager
@@ -7,6 +9,8 @@
79
import pytest
810
from cryptography import x509
911
from cryptography.hazmat.backends import default_backend
12+
from cryptography.hazmat.primitives import hashes, serialization
13+
from cryptography.hazmat.primitives.asymmetric import rsa
1014
from cryptography.x509.oid import ExtensionOID
1115
from ops import CharmBase
1216
from scenario import Context, PeerRelation, Relation, State
@@ -43,6 +47,71 @@ def _mock_san(self):
4347
return None
4448

4549

50+
def generate_certificate_and_key():
51+
"""Generate certificate and CA to use for tests."""
52+
# Generate private key
53+
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
54+
55+
# Generate CA certificate
56+
ca_subject = issuer = x509.Name(
57+
[
58+
x509.NameAttribute(x509.NameOID.COUNTRY_NAME, "US"),
59+
x509.NameAttribute(x509.NameOID.STATE_OR_PROVINCE_NAME, "California"),
60+
x509.NameAttribute(x509.NameOID.LOCALITY_NAME, "San Francisco"),
61+
x509.NameAttribute(x509.NameOID.ORGANIZATION_NAME, "Example CA"),
62+
x509.NameAttribute(x509.NameOID.COMMON_NAME, "example.com"),
63+
]
64+
)
65+
66+
ca_cert = (
67+
x509.CertificateBuilder()
68+
.subject_name(ca_subject)
69+
.issuer_name(issuer)
70+
.public_key(private_key.public_key())
71+
.serial_number(x509.random_serial_number())
72+
.not_valid_before(datetime.datetime.utcnow())
73+
.not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=365))
74+
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
75+
.sign(private_key, hashes.SHA256())
76+
)
77+
78+
# Generate server certificate
79+
server_subject = x509.Name(
80+
[
81+
x509.NameAttribute(x509.NameOID.COUNTRY_NAME, "US"),
82+
x509.NameAttribute(x509.NameOID.STATE_OR_PROVINCE_NAME, "California"),
83+
x509.NameAttribute(x509.NameOID.LOCALITY_NAME, "San Francisco"),
84+
x509.NameAttribute(x509.NameOID.ORGANIZATION_NAME, "Example Server"),
85+
x509.NameAttribute(x509.NameOID.COMMON_NAME, "server.example.com"),
86+
]
87+
)
88+
89+
server_cert = (
90+
x509.CertificateBuilder()
91+
.subject_name(server_subject)
92+
.issuer_name(ca_subject)
93+
.public_key(private_key.public_key())
94+
.serial_number(x509.random_serial_number())
95+
.not_valid_before(datetime.datetime.utcnow())
96+
.not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=30))
97+
.add_extension(
98+
x509.SubjectAlternativeName([x509.DNSName("server.example.com")]), critical=False
99+
)
100+
.sign(private_key, hashes.SHA256())
101+
)
102+
103+
# Convert to PEM format
104+
ca_cert_pem = ca_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
105+
server_cert_pem = server_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
106+
private_key_pem = private_key.private_bytes(
107+
encoding=serialization.Encoding.PEM,
108+
format=serialization.PrivateFormat.TraditionalOpenSSL,
109+
encryption_algorithm=serialization.NoEncryption(),
110+
).decode("utf-8")
111+
112+
return ca_cert_pem, server_cert_pem, private_key_pem
113+
114+
46115
def get_csr_obj(csr: str):
47116
return x509.load_pem_x509_csr(csr.encode(), default_backend())
48117

@@ -174,3 +243,30 @@ def test_csr_no_change(ctx, certificates):
174243
csr = get_csr_obj(charm.ch._csr)
175244
assert get_sans_from_csr(csr) == {socket.getfqdn()}
176245
assert renew_patch.call_count == 0
246+
247+
248+
def test_chain_contains_server_cert(ctx, certificates):
249+
ca_cert_pem, server_cert_pem, _ = generate_certificate_and_key()
250+
251+
certificates = certificates.replace(
252+
remote_app_data={
253+
"certificates": json.dumps(
254+
[
255+
{
256+
"certificate": server_cert_pem,
257+
"ca": ca_cert_pem,
258+
"chain": [ca_cert_pem],
259+
"certificate_signing_request": "csr",
260+
}
261+
],
262+
)
263+
},
264+
local_unit_data={
265+
"certificate_signing_requests": json.dumps([{"certificate_signing_request": "csr"}])
266+
},
267+
)
268+
269+
with ctx.manager("update_status", State(leader=True, relations=[certificates])) as mgr:
270+
mgr.run()
271+
assert server_cert_pem in mgr.charm.ch.chain
272+
assert x509.load_pem_x509_certificate(mgr.charm.ch.chain.encode(), default_backend())

0 commit comments

Comments
 (0)