Merge ~woutervb/snap-store-proxy-charm:SN-484_support_certificates into snap-store-proxy-charm:master
- Git
- lp:~woutervb/snap-store-proxy-charm
- SN-484_support_certificates
- Merge into master
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) |
Related bugs: |
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.
Description of the change
- 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.
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
1 | diff --git a/Makefile b/Makefile |
2 | index 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 | |
20 | diff --git a/config.yaml b/config.yaml |
21 | index 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 |
41 | diff --git a/conftest.py b/conftest.py |
42 | index 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() |
70 | diff --git a/requirements.txt b/requirements.txt |
71 | index 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 |
79 | diff --git a/src/charm.py b/src/charm.py |
80 | index 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 | |
210 | diff --git a/src/helpers.py b/src/helpers.py |
211 | index 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() |
244 | diff --git a/tests/test_charm.py b/tests/test_charm.py |
245 | index 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 |
483 | diff --git a/tests/test_helpers.py b/tests/test_helpers.py |
484 | index 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 |
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!