Merge ~woutervb/snap-store-proxy-charm:SN-484_support_certificates into snap-store-proxy-charm:master

Proposed by Wouter van Bommel
Status: Merged
Approved by: Wouter van Bommel
Approved revision: a854da0e762c6165fdb3a055c6c088543179f0ae
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~woutervb/snap-store-proxy-charm:SN-484_support_certificates
Merge into: snap-store-proxy-charm:master
Diff against target: 524 lines (+360/-16)
8 files modified
Makefile (+3/-3)
config.yaml (+12/-1)
conftest.py (+16/-0)
requirements.txt (+1/-0)
src/charm.py (+80/-3)
src/helpers.py (+10/-3)
tests/test_charm.py (+218/-0)
tests/test_helpers.py (+20/-6)
Reviewer Review Type Date Requested Status
Jonathan Hartley (community) Approve
Review via email: mp+417457@code.launchpad.net

Commit message

Add handling of tls certificates

When using certificates, we should:
 * Validate that the correct name is mentioned in the certificate
 * Validate that the key and certificate match
 * That we open the correct port
 * That we close the correct port
 * Validate that the correct encoding is used

We explicitly don't check the certificate chain or CA, as that is
something that is up to the endpoint (snapd) to validate and with custom
certificates there is a change that we don't have all the needed
information.

To post a comment you must log in.
Revision history for this message
Jonathan Hartley (tartley) wrote :

This looks fabulous. I have a few nitwit ideas in the diffs, take any you like, but obviously ignore them if you don't.

Approved!

review: Approve
a854da0... by Wouter van Bommel

Reworked error handling in handle_certificate

As suggested in review feedback error handling could be simplified, have
done that now and code looks cleaner and is more consistend.

Revision history for this message
Wouter van Bommel (woutervb) wrote :

@Jonathan, thanks for the feedback, have taken over your suggestion around error handling as this simplifies complexity and improves consistency

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/Makefile b/Makefile
2index cea8799..c852b89 100644
3--- a/Makefile
4+++ b/Makefile
5@@ -55,11 +55,11 @@ black: $(ENV)
6 .PHONY: lint
7 lint: $(ENV)
8 @echo Running flake8
9- @$(ENV)/bin/flake8 src tests
10+ @$(ENV)/bin/flake8 src tests conftest.py
11 @echo Running black
12- @$(ENV)/bin/black --check src tests
13+ @$(ENV)/bin/black --check src tests conftest.py
14 @echo Running isort
15- @$(ENV)/bin/isort --profile black --check src tests
16+ @$(ENV)/bin/isort --profile black --check src tests conftest.py
17 @echo Running documentation lint
18 @$(ENV)/bin/pymarkdown -d MD041,MD025 scan docs
19
20diff --git a/config.yaml b/config.yaml
21index 1bf660f..3ebebd2 100644
22--- a/config.yaml
23+++ b/config.yaml
24@@ -11,4 +11,15 @@ options:
25 Registration file generated via `store-admin`, added as ie \
26 $(cat <generated file>)
27 type: string
28-
29+ certificate:
30+ default: null
31+ description: |
32+ The public part of the certificate that matches the hostname,\
33+ PEM encoded. Add like $(cat <pem certificate>)
34+ type: string
35+ private_key:
36+ default: null
37+ description: |
38+ The private part of the certificate that matches the hostname,\
39+ PEM encoded, without an password. Add like $(cat <pem private>)
40+ type: string
41diff --git a/conftest.py b/conftest.py
42index 31b44ee..1b06028 100644
43--- a/conftest.py
44+++ b/conftest.py
45@@ -3,8 +3,24 @@
46 #
47
48 import pytest
49+from ops.testing import Harness
50
51
52 @pytest.fixture(autouse=True)
53 def dummy_install_from_the_charmstore(mocker):
54 mocker.patch("charm.install_from_the_charmstore")
55+
56+
57+@pytest.fixture()
58+def harness():
59+ # Helper so we have a 'new' instance every time, which is useful on
60+ # occasion.
61+ # We can't import charm at a module level, as the search paths aren't
62+ # setup at the moment this file is read by pytest. We also can't import
63+ # the SnapstoreProxyCharm object directly for similar reasons.
64+ import charm
65+
66+ harness = Harness(charm.SnapstoreProxyCharm)
67+ harness.begin()
68+ yield harness
69+ harness.cleanup()
70diff --git a/requirements.txt b/requirements.txt
71index c29abf0..ac74b07 100644
72--- a/requirements.txt
73+++ b/requirements.txt
74@@ -1,2 +1,3 @@
75 ops >= 1.2.0
76 ops-lib-pgsql
77+cryptography
78\ No newline at end of file
79diff --git a/src/charm.py b/src/charm.py
80index 043114a..4a64d8f 100755
81--- a/src/charm.py
82+++ b/src/charm.py
83@@ -11,6 +11,8 @@ import json
84 import logging
85 from urllib.parse import urlparse
86
87+from cryptography import x509
88+from cryptography.hazmat.primitives import serialization
89 from ops.charm import CharmBase
90 from ops.framework import StoredState
91 from ops.lib import use
92@@ -168,7 +170,6 @@ class SnapstoreProxyCharm(CharmBase):
93
94 for item in all_config_options.keys():
95 logger.debug(f"Testing config item {item} for a new value")
96-
97 # try to get the value from the charm config, if set
98 new_value = getattr(self._stored, item, default_values.get(item, None))
99 if new_value is None:
100@@ -195,8 +196,11 @@ class SnapstoreProxyCharm(CharmBase):
101 actual snap entries"""
102
103 has_registration_bundle = False
104+ has_certificate_update = False
105 for item in updates:
106 has_registration_bundle |= item["name"] == "registration_bundle"
107+ has_certificate_update |= item["name"] == "certificate"
108+ has_certificate_update |= item["name"] == "private_key"
109
110 if has_registration_bundle:
111 for item in updates:
112@@ -237,7 +241,7 @@ class SnapstoreProxyCharm(CharmBase):
113 logger.debug(f"Status of tls is {self._stored.ssl}")
114
115 # Now make 2 lists, option names and values where the indexes
116- # correspond with eachother
117+ # correspond with each other
118 options = []
119 values = []
120 for entry in data.keys():
121@@ -263,6 +267,74 @@ class SnapstoreProxyCharm(CharmBase):
122 logger.debug("Stored the store id from the registration bundle")
123 configure_proxy(options, values, force=True)
124
125+ if has_certificate_update:
126+ errors = self.handle_certificate()
127+ if errors:
128+ logger.error(errors)
129+ self.errors.append(errors)
130+
131+ def handle_certificate(self):
132+ # If we are called, then we know that the config has changed, and that
133+ # self._stored["certificate|private_key"] contains the expected values
134+
135+ # See if the certificate matches the key, which is basically compare the
136+ # modules of the 2 certificate parts, they should be equal
137+ try:
138+ raw_certificate = getattr(self._stored, "certificate", None)
139+ if raw_certificate is None:
140+ return "Missing TLS certificate"
141+ cert = x509.load_pem_x509_certificate(raw_certificate.encode())
142+ except (ValueError, TypeError) as exc:
143+ logger.warning(f"Problem {str(exc)}, while loading certificate")
144+ return "Problem loading the certificate, they should be PEM encoded strings"
145+
146+ try:
147+ raw_key = getattr(self._stored, "private_key", None)
148+ if raw_key is None:
149+ self.errors.append("Missing TLS private key")
150+ return
151+ key = serialization.load_pem_private_key(raw_key.encode(), password=None)
152+ except (ValueError, TypeError) as exc:
153+ logger.warning(f"Problem {str(exc)}, while loading certificate")
154+ return "Problem loading the private_key, they should be PEM encoded strings"
155+
156+ if cert.public_key().public_numbers() != key.private_numbers().public_numbers:
157+ return "The certificate and the key don't match, skipping processing them"
158+
159+ # Build a list of names that are embedded in the certificate, start with the old
160+ # (deprecated) CN
161+ cert_names = set(
162+ cn.value for cn in cert.subject.get_attributes_for_oid(x509.OID_COMMON_NAME)
163+ )
164+ # Now handle SANs
165+ try:
166+ san = cert.extensions.get_extension_for_oid(
167+ x509.OID_SUBJECT_ALTERNATIVE_NAME
168+ )
169+ except x509.extensions.ExtensionNotFound:
170+ pass
171+ else:
172+ cert_names.update(san.value.get_values_for_type(x509.DNSName))
173+ cert_names.update(
174+ str(ip) for ip in san.value.get_values_for_type(x509.IPAddress)
175+ )
176+
177+ domain = getattr(self._stored, "domain")
178+ if domain not in cert_names:
179+ logger.warning(f"Could not find {domain} in list {cert_names}")
180+ return "Certificate does not contain the proxy's registered name"
181+
182+ # Okay, the certificate and private key do make sense and match this host,
183+ # so let's install them
184+ configure_proxy(
185+ ["proxy.tls.cert", "proxy.tls.key"],
186+ [
187+ getattr(self._stored, "certificate"),
188+ getattr(self._stored, "private_key"),
189+ ],
190+ )
191+ logger.info("Successfully installed the tls certificate on the proxy")
192+
193 def handle_pgsql_dsn_change(self):
194 """If the database is configured, then we try to configure the snap."""
195 installation_source = getattr(self._stored, "snap_install_source", None)
196@@ -292,7 +364,12 @@ class SnapstoreProxyCharm(CharmBase):
197 return
198
199 # We are in a proper state, let's open the port
200- open_port(ssl=getattr(self._stored, "ssl", False))
201+ # We only start listening if we have a certificate, so check for a
202+ # certificate pair, and only then open the ssl port
203+ is_ssl = (getattr(self._stored, "private_key", None) is not None) and (
204+ getattr(self._stored, "certificate", None) is not None
205+ )
206+ open_port(ssl=is_ssl)
207
208 self.unit.status = ActiveStatus(f"Running on: {self._stored.domain}")
209
210diff --git a/src/helpers.py b/src/helpers.py
211index f4000ac..9a82576 100644
212--- a/src/helpers.py
213+++ b/src/helpers.py
214@@ -48,6 +48,10 @@ def config_options():
215 for item in config["options"].keys():
216 if item == "registration_bundle":
217 type_dict.update({item: "base64+json"})
218+ if item == "certificate":
219+ type_dict.update({item: "string"})
220+ if item == "private_key":
221+ type_dict.update({item: "string"})
222
223 return type_dict, default_value
224
225@@ -56,12 +60,15 @@ def open_port(ssl=False):
226 # Based on charm-helpers open_port function, as this is not provided by
227 # the charm framework
228 if ssl:
229- port = 443
230+ open_port = 443
231+ close_port = 80
232 else:
233- port = 80
234+ open_port = 80
235+ close_port = 443
236
237 # Don't catch exceptions, just lett them pass on
238- check_call(["open-port", f"{port}/tcp"])
239+ check_call(["open-port", f"{open_port}/tcp"])
240+ check_call(["close-port", f"{close_port}/tcp"])
241
242
243 all_config_options, default_values = config_options()
244diff --git a/tests/test_charm.py b/tests/test_charm.py
245index b92efce..eff60e8 100644
246--- a/tests/test_charm.py
247+++ b/tests/test_charm.py
248@@ -6,12 +6,20 @@
249
250 import base64
251 import hashlib
252+import random
253+import string
254 import subprocess
255+from datetime import datetime, timedelta
256 from pathlib import Path
257 from unittest.mock import MagicMock, patch
258
259 import pytest
260 import yaml
261+from cryptography import x509
262+from cryptography.hazmat.backends import default_backend
263+from cryptography.hazmat.primitives import hashes, serialization
264+from cryptography.hazmat.primitives.asymmetric import rsa
265+from cryptography.x509.oid import NameOID
266 from ops.model import BlockedStatus
267 from ops.testing import Harness
268
269@@ -541,3 +549,213 @@ class TestInstallation(CharmTest):
270 assert not hasattr(self.harness.charm._stored, "snap_md5")
271 assert len(self.harness.charm.errors) == 1
272 assert self.harness.charm.errors[0] == "Failed to install the snap-store-proxy"
273+
274+
275+class TestTLSCertificate(CharmTest):
276+ @staticmethod
277+ def setup_harness(in_harness):
278+ # Set some default values so we don't have to repeat them for every test
279+ in_harness.charm._stored.db_uri = "postgresql://fake/database"
280+ in_harness.charm._stored.snap_install_source = "store"
281+ in_harness.charm._stored.database_created = True
282+ in_harness.charm._stored.registered = True
283+
284+ @staticmethod
285+ def create_x509_certificate(name, is_san):
286+ """Helper to generate a certificate + private key
287+
288+ is is_san is true, then the name will be placed in the SAN section.
289+ The certificate generated, will be self signed."""
290+ key = rsa.generate_private_key(
291+ public_exponent=65537, key_size=4048, backend=default_backend()
292+ )
293+
294+ basic_contraints = x509.BasicConstraints(ca=True, path_length=0)
295+ now = datetime.utcnow()
296+ encoded_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, name)])
297+
298+ if is_san:
299+ alt_names = [x509.DNSName(name)]
300+ san = x509.SubjectAlternativeName(alt_names)
301+ # Just generate a string of 12 random characters
302+ name = x509.Name(
303+ [
304+ x509.NameAttribute(
305+ NameOID.COMMON_NAME,
306+ "".join(random.choice(string.ascii_letters) for x in range(12)),
307+ )
308+ ]
309+ )
310+ cert = (
311+ x509.CertificateBuilder()
312+ .subject_name(name)
313+ .issuer_name(encoded_name)
314+ .public_key(key.public_key())
315+ .serial_number(1000)
316+ .not_valid_before(now)
317+ .not_valid_after(now + timedelta(days=10 * 165))
318+ .add_extension(basic_contraints, False)
319+ .add_extension(san, False)
320+ .sign(key, hashes.SHA256(), default_backend())
321+ )
322+ else:
323+ cert = (
324+ x509.CertificateBuilder()
325+ .subject_name(encoded_name)
326+ .issuer_name(encoded_name)
327+ .public_key(key.public_key())
328+ .serial_number(1000)
329+ .not_valid_before(now)
330+ .not_valid_after(now + timedelta(days=10 * 165))
331+ .add_extension(basic_contraints, False)
332+ .sign(key, hashes.SHA256(), default_backend())
333+ )
334+ cert_pem = cert.public_bytes(encoding=serialization.Encoding.PEM)
335+ key_pem = key.private_bytes(
336+ encoding=serialization.Encoding.PEM,
337+ format=serialization.PrivateFormat.TraditionalOpenSSL,
338+ encryption_algorithm=serialization.NoEncryption(),
339+ )
340+
341+ return cert_pem.decode(), key_pem.decode()
342+
343+ @pytest.mark.parametrize("san", (True, False))
344+ def test_all_okay(self, san, harness):
345+
346+ self.setup_harness(harness)
347+
348+ # Test for correct handling of missing private key
349+ domain = "http://test.domain.just.invented"
350+ harness.charm._stored.domain = domain
351+ cert, key = self.create_x509_certificate(domain, san)
352+
353+ # Since valid values will be pushed on to the proxy, we need to stub that here
354+ with patch("charm.configure_proxy") as m_configure, patch(
355+ "charm.open_port"
356+ ) as m_open_port:
357+ # Ensure that the default values are set, as expected
358+ harness.update_config({"certificate": cert, "private_key": key})
359+
360+ m_configure.assert_called_once_with(
361+ ["proxy.tls.cert", "proxy.tls.key"], [cert, key]
362+ )
363+ m_open_port.assert_called_once_with(ssl=True)
364+
365+ assert len(harness.charm.errors) == 0
366+
367+ @pytest.mark.parametrize("san", (True, False))
368+ def test_domain_mismatch(self, san, harness):
369+
370+ self.setup_harness(harness)
371+
372+ # Test for correct handling of missing private key
373+ domain = "http://test.domain.just.invented"
374+ harness.charm._stored.domain = domain + ".some.added.test"
375+ cert, key = self.create_x509_certificate(domain, san)
376+
377+ # Since valid values will be pushed on to the proxy, we need to stub that here
378+ with patch("charm.configure_proxy") as m_configure, patch(
379+ "charm.open_port"
380+ ) as m_open_port:
381+ # Ensure that the default values are set, as expected
382+ harness.update_config({"certificate": cert, "private_key": key})
383+
384+ m_configure.assert_not_called()
385+ m_open_port.assert_not_called()
386+
387+ assert len(harness.charm.errors) == 1
388+ assert harness.charm.errors == [
389+ "Certificate does not contain the proxy's registered name"
390+ ]
391+
392+ def test_only_certificate(self, harness):
393+ self.setup_harness(harness)
394+
395+ # Test for correct handling of missing private key
396+ cert, key = self.create_x509_certificate("https://test.domain", False)
397+
398+ # Since valid values will be pushed on to the proxy, we need to stub that here
399+ with patch("charm.configure_proxy"):
400+ # Ensure that the default values are set, as expected
401+ harness.update_config({"certificate": cert})
402+
403+ assert harness.charm.errors == ["Missing TLS private key"]
404+ assert len(harness.charm.errors) == 1
405+
406+ def test_only_private_key(self, harness):
407+
408+ self.setup_harness(harness)
409+
410+ # Test for correct handling of missing private key
411+ cert, key = self.create_x509_certificate("https://test.domain", False)
412+
413+ # Since valid values will be pushed on to the proxy, we need to stub that here
414+ with patch("charm.configure_proxy"):
415+ # Ensure that the default values are set, as expected
416+ harness.update_config({"private_key": key})
417+
418+ assert harness.charm.errors == ["Missing TLS certificate"]
419+ assert len(harness.charm.errors) == 1
420+
421+ def test_key_mismatch(self, harness):
422+
423+ self.setup_harness(harness)
424+
425+ # Test for correct handling of missing private key
426+ domain = "http://test.domain.just.invented"
427+ harness.charm._stored.domain = domain
428+ cert1, key1 = self.create_x509_certificate(domain, False)
429+ cert2, key2 = self.create_x509_certificate(domain, False)
430+
431+ # Since valid values will be pushed on to the proxy, we need to stub that here
432+ with patch("charm.configure_proxy") as m_configure, patch(
433+ "charm.open_port"
434+ ) as m_open_port:
435+ # Ensure that the default values are set, as expected
436+ harness.update_config({"certificate": cert1, "private_key": key2})
437+
438+ m_configure.assert_not_called()
439+ m_open_port.assert_not_called()
440+
441+ assert len(harness.charm.errors) == 1
442+ assert harness.charm.errors == [
443+ "The certificate and the key don't match, skipping processing them"
444+ ]
445+
446+ def test_private_key_bad_encoding(self, harness):
447+
448+ self.setup_harness(harness)
449+
450+ # Test for correct handling of missing private key
451+ cert, key = self.create_x509_certificate("https://test.domain", False)
452+
453+ # Since valid values will be pushed on to the proxy, we need to stub that here
454+ with patch("charm.configure_proxy"):
455+ # Ensure that the default values are set, as expected
456+ harness.update_config(
457+ {"private_key": "My invalid key", "certificate": cert}
458+ )
459+
460+ assert harness.charm.errors == [
461+ "Problem loading the private_key, they should be PEM encoded strings"
462+ ]
463+ assert len(harness.charm.errors) == 1
464+
465+ def test_certificate_bad_encoding(self, harness):
466+
467+ self.setup_harness(harness)
468+
469+ # Test for correct handling of missing private key
470+ cert, key = self.create_x509_certificate("https://test.domain", False)
471+
472+ # Since valid values will be pushed on to the proxy, we need to stub that here
473+ with patch("charm.configure_proxy"):
474+ # Ensure that the default values are set, as expected
475+ harness.update_config(
476+ {"private_key": key, "certificate": "My completely invalid certificate"}
477+ )
478+
479+ assert harness.charm.errors == [
480+ "Problem loading the certificate, they should be PEM encoded strings"
481+ ]
482+ assert len(harness.charm.errors) == 1
483diff --git a/tests/test_helpers.py b/tests/test_helpers.py
484index a62308c..b81b358 100644
485--- a/tests/test_helpers.py
486+++ b/tests/test_helpers.py
487@@ -1,7 +1,5 @@
488 from unittest import mock
489
490-import pytest
491-
492 import helpers
493
494
495@@ -58,9 +56,25 @@ def test_config_options():
496 assert default_value_key in [key for key in all_options.keys()]
497
498
499-@pytest.mark.parametrize("ssl,port", [(True, 443), (False, 80)])
500-def test_open_port(ssl, port):
501+def test_open_ssl_port():
502 with mock.patch("helpers.check_call") as mocked_call:
503- helpers.open_port(ssl)
504+ helpers.open_port(True)
505+
506+ mocked_call.assert_has_calls(
507+ [mock.call(["open-port", "443/tcp"]), mock.call(["close-port", "80/tcp"])],
508+ any_order=False,
509+ )
510+
511+ assert mocked_call.call_count == 2
512+
513+
514+def test_open_http_port():
515+ with mock.patch("helpers.check_call") as mocked_call:
516+ helpers.open_port(False)
517+
518+ mocked_call.assert_has_calls(
519+ [mock.call(["open-port", "80/tcp"]), mock.call(["close-port", "443/tcp"])],
520+ any_order=False,
521+ )
522
523- mocked_call.assert_called_once_with(["open-port", f"{port}/tcp"])
524+ assert mocked_call.call_count == 2

Subscribers

People subscribed via source and target branches

to all changes: