Skip to content

Commit 28cf12b

Browse files
committed
mtls: add test
Signed-off-by: Florian Bezannier <[email protected]>
1 parent 1a42d68 commit 28cf12b

File tree

7 files changed

+339
-29
lines changed

7 files changed

+339
-29
lines changed

README.md

+4-1
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,10 @@ Run hawkBit docker container:
113113
$ docker pull hawkbit/hawkbit-update-server
114114
$ docker run -d --name hawkbit -p 8080:8080 hawkbit/hawkbit-update-server \
115115
--hawkbit.server.security.dos.filter.enabled=false \
116-
--hawkbit.server.security.dos.maxStatusEntriesPerAction=-1
116+
--hawkbit.server.security.dos.maxStatusEntriesPerAction=-1 \
117+
--hawkbit.dmf.rabbitmq.enabled=false \
118+
--server.forward-headers-strategy=NATIVE \
119+
--hawkbit.artifact.url.protocols.download-http.protocol=<https or http>
117120
```
118121

119122
Run test suite:

test/conftest.py

+139-16
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
import pytest
1010

1111
from hawkbit_mgmt import HawkbitMgmtTestClient, HawkbitError
12-
from helper import run_pexpect, available_port
12+
from helper import run_pexpect, available_port, run
13+
from mtls_conf import MtlsConfig
1314

1415
def pytest_addoption(parser):
1516
"""Register custom argparse-style options."""
@@ -103,8 +104,11 @@ def _adjust_config(options={'client': {}}, remove={}, add_trailing_space=False):
103104
adjusted_config.set(section, key, value)
104105

105106
# remove
106-
for section, option in remove.items():
107-
adjusted_config.remove_option(section, option)
107+
for section, options in remove.items():
108+
if type(options) is str:
109+
options = [options]
110+
for option in options:
111+
adjusted_config.remove_option(section, option)
108112

109113
# add trailing space
110114
if add_trailing_space:
@@ -209,6 +213,24 @@ def rauc_dbus_install_success(rauc_bundle):
209213
assert proc.terminate(force=True)
210214
proc.expect(pexpect.EOF)
211215

216+
@pytest.fixture
217+
def rauc_dbus_install_success_mtls(rauc_bundle,tmp_path_factory):
218+
"""
219+
Creates a RAUC D-Bus dummy interface on the SessionBus mimicing a successful installation on
220+
InstallBundle().
221+
"""
222+
import pexpect
223+
224+
proc = run_pexpect(f'{sys.executable} -m rauc_dbus_dummy --tmp-dir {tmp_path_factory.getbasetemp()} --mtls {rauc_bundle}',
225+
cwd=os.path.dirname(__file__))
226+
proc.expect('Interface published')
227+
228+
yield
229+
230+
assert proc.isalive()
231+
assert proc.terminate(force=True)
232+
proc.expect(pexpect.EOF)
233+
212234
@pytest.fixture
213235
def rauc_dbus_install_failure(rauc_bundle):
214236
"""
@@ -242,11 +264,26 @@ def nginx_config(tmp_path_factory):
242264
243265
http {{
244266
access_log /dev/null;
267+
map $ssl_client_s_dn $ssl_client_s_dn_cn {{
268+
default "";
269+
~CN=(?<CN>[^,]+) $CN;
270+
}}
245271
272+
{server}
273+
}}
274+
"""
275+
http_server = """
246276
server {{
247277
listen {port};
248278
listen [::]:{port};
249-
279+
280+
client_body_temp_path {tmp_dir};
281+
proxy_temp_path {tmp_dir};
282+
fastcgi_temp_path {tmp_dir};
283+
uwsgi_temp_path {tmp_dir};
284+
scgi_temp_path {tmp_dir};
285+
{server_options}
286+
250287
location / {{
251288
proxy_pass http://localhost:8080;
252289
{location_options}
@@ -258,12 +295,63 @@ def nginx_config(tmp_path_factory):
258295
sub_filter_once off;
259296
}}
260297
}}
261-
}}"""
262-
263-
def _nginx_config(port, location_options):
264-
proxy_config = tmp_path_factory.mktemp('nginx') / 'nginx.conf'
265-
location_options = ( f'{key} {value};' for key, value in location_options.items())
266-
proxy_config_str = config_template.format(port=port, location_options=" ".join(location_options))
298+
"""
299+
mtls_server = """
300+
server {{
301+
listen {port} ssl;
302+
listen [::]:{port} ssl;
303+
304+
client_body_temp_path {tmp_dir};
305+
proxy_temp_path {tmp_dir};
306+
fastcgi_temp_path {tmp_dir};
307+
uwsgi_temp_path {tmp_dir};
308+
scgi_temp_path {tmp_dir};
309+
server_name localhost;
310+
{server_options}
311+
312+
313+
ssl_verify_client optional;
314+
ssl_verify_depth 3;
315+
# For devices that is using device integration API,
316+
# Mutual TLS is required.
317+
location ~*/.*/controller/ {{
318+
if ($ssl_client_verify != SUCCESS) {{
319+
return 403;
320+
}}
321+
322+
proxy_pass http://localhost:8080;
323+
proxy_set_header Host $http_host;
324+
proxy_set_header X-Real-IP $remote_addr;
325+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
326+
proxy_set_header X-Forwarded-Proto https;
327+
proxy_set_header X-Forwarded-Port {port};
328+
proxy_set_header X-Forwarded-Protocol https;
329+
330+
# Client certificate Common Name and Issuer Hash is required
331+
# for auth in hawkbit.
332+
proxy_set_header X-Ssl-Client-Cn $ssl_client_s_dn_cn;
333+
334+
# These are required for clients to upload and download software.
335+
proxy_request_buffering off;
336+
client_max_body_size 1000m;
337+
{location_options}
338+
}}
339+
}}
340+
"""
341+
def dict_to_nginx_option(option:dict):
342+
if not option:
343+
return ""
344+
option = ( f'{key} {value};' for key, value in option.items())
345+
return " ".join(option)
346+
def _nginx_config(port, location_options, server_options, mtls):
347+
nginx_tmp_dir=tmp_path_factory.mktemp('nginx')
348+
server_config = mtls_server if mtls else http_server
349+
server_config_str = server_config.format(port=port,location_options=dict_to_nginx_option(location_options),
350+
server_options=dict_to_nginx_option(server_options),
351+
tmp_dir=nginx_tmp_dir)
352+
353+
proxy_config_str = config_template.format(port=port,server=server_config_str, tmp_dir=nginx_tmp_dir)
354+
proxy_config = nginx_tmp_dir / 'nginx.conf'
267355
proxy_config.write_text(proxy_config_str)
268356
return proxy_config
269357

@@ -272,18 +360,16 @@ def _nginx_config(port, location_options):
272360
@pytest.fixture(scope='session')
273361
def nginx_proxy(nginx_config):
274362
"""
275-
Runs an nginx rate liming proxy, limiting download speeds to 70 KB/s. HTTP requests are
363+
Runs an nginx proxy. HTTP requests are
276364
forwarded to port 8080 (default port of the docker hawkBit instance). Returns the port the
277-
proxy is running on. This port can be set in the rauc-hawkbit-updater config to rate limit its
278-
HTTP requests.
365+
proxy is running on. This port can be set in the rauc-hawkbit-updater config.
279366
"""
280367
import pexpect
281368

282369
procs = []
283-
284-
def _nginx_proxy(options):
370+
def _nginx_proxy(options, server_options=None, mtls=False):
285371
port = available_port()
286-
proxy_config = nginx_config(port, options)
372+
proxy_config = nginx_config(port, options, server_options, mtls)
287373

288374
try:
289375
proc = run_pexpect(f'nginx -c {proxy_config} -p .', timeout=None)
@@ -332,3 +418,40 @@ def partial_download_port(nginx_proxy):
332418
'limit_rate': '70k',
333419
}
334420
return nginx_proxy(location_options)
421+
422+
@pytest.fixture
423+
def mtls_download_port(nginx_proxy, mtls_certificates):
424+
"""
425+
Runs an nginx proxy. HTTPS requests are forwarded to port 8080
426+
(default port of the docker hawkBit instance). Returns the port the proxy is running on. This
427+
port can be set in the rauc-hawkbit-updater config to test partial downloads.
428+
"""
429+
mtls_cert = mtls_certificates()
430+
hash_issuer = mtls_cert.get_issuer_hash()
431+
location_options = {"proxy_set_header X-Ssl-Issuer-Hash-1": hash_issuer}
432+
server_options = {
433+
"ssl_certificate": mtls_cert.ca_cert,
434+
"ssl_certificate_key": mtls_cert.ca_key,
435+
"ssl_client_certificate": mtls_cert.ca_cert
436+
}
437+
return nginx_proxy(location_options, server_options, mtls=True)
438+
439+
440+
441+
@pytest.fixture
442+
def mtls_config(tmp_path_factory):
443+
return MtlsConfig(tmp_path_factory.getbasetemp())
444+
445+
@pytest.fixture
446+
def mtls_certificates(mtls_config, tmp_path_factory, hawkbit_target_added):
447+
"""
448+
Generate CA cert and key if they don't exist and also generate specific client cert and key
449+
for the Hawkbit controller id as Common Name.
450+
"""
451+
def _mtls_certificates():
452+
out, err, exitcode = run(f'{os.path.dirname(__file__)}/gen_key.sh {mtls_config.certs_dir} {hawkbit_target_added}', timeout=20)
453+
assert exitcode == 0
454+
assert mtls_config.ca_cert_exist()
455+
assert mtls_config.client_cert_exist()
456+
return mtls_config
457+
return _mtls_certificates

test/gen_key.sh

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
#!/bin/bash
2+
set -e
3+
CERT_DIR="${1}"
4+
CONTROLLER_ID="${2}"
5+
CA_CERT="root-ca.crt"
6+
CA_KEY="root-ca.key"
7+
CA_CSR="root-csr.pem"
8+
CERT_CONFIG='
9+
[client]
10+
basicConstraints = CA:FALSE
11+
nsCertType = client, email
12+
nsComment = "Local Test Client Certificate"
13+
subjectKeyIdentifier = hash
14+
authorityKeyIdentifier = keyid,issuer
15+
keyUsage = critical, nonRepudiation, digitalSignature, keyEncipherment
16+
extendedKeyUsage = clientAuth
17+
'
18+
19+
mkdir -p ${CERT_DIR}
20+
cd ${CERT_DIR}
21+
if [ ! -f "${CA_CERT}" ]; then
22+
echo "Development CA"
23+
openssl req -newkey rsa -keyout ${CA_KEY} -out ${CA_CSR} -subj "/O=Test/CN=localhost" --nodes
24+
openssl req -x509 -sha256 -new -nodes -key ${CA_KEY} -days 3650 -out ${CA_CERT} -subj '/CN=localhost'
25+
fi
26+
if [ -n "${CONTROLLER_ID}" ]; then
27+
openssl genrsa -out "client.key" 4096
28+
openssl req -new -key "client.key" -out "client.csr" -sha256 -subj "/CN=${CONTROLLER_ID}"
29+
30+
openssl x509 -req -days 750 -in "client.csr" -sha256 -CA ${CA_CERT} -CAkey ${CA_KEY} -CAcreateserial -out "client.crt" -extensions client -extfile <(printf ${CERT_CONFIG})
31+
openssl x509 -in client.crt -issuer_hash -noout > issuer_hash.txt
32+
fi

test/mtls_conf.py

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import os
2+
3+
4+
class MtlsConfig:
5+
def __init__(self, cert_dir_location):
6+
self.certs_dir= str(cert_dir_location) + "/certs/"
7+
self.ca_cert= self.certs_dir + "root-ca.crt"
8+
self.ca_key= self.certs_dir + "root-ca.key"
9+
self.ca_csr= self.certs_dir + "root-csr.pem"
10+
self.client_cert= self.certs_dir + "client.crt"
11+
self.client_key= self.certs_dir + "client.key"
12+
self.issuer_hash = self. certs_dir + "issuer_hash.txt"
13+
def client_cert_exist(self):
14+
return os.path.isfile(self.client_cert)
15+
16+
def ca_cert_exist(self):
17+
return os.path.isfile(self.ca_cert)
18+
19+
def get_issuer_hash(self):
20+
return open(self.issuer_hash, "r").readline().strip()

test/rauc_dbus_dummy.py

+27-12
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
from pydbus.generic import signal
1111
import requests
1212

13+
from mtls_conf import MtlsConfig
14+
1315

1416
class Installer:
1517
"""
@@ -28,13 +30,15 @@ class Installer:
2830
Completed = signal()
2931
PropertiesChanged = signal()
3032

31-
def __init__(self, bundle, completed_code=0):
33+
def __init__(self, bundle, mtls,tmp_path, completed_code=0):
3234
self._bundle = bundle
3335
self._completed_code = completed_code
3436

3537
self._operation = 'idle'
3638
self._last_error = ''
3739
self._progress = 0, '', 1
40+
self._mtls = mtls
41+
self.tmp_path = tmp_path
3842

3943
def InstallBundle(self, source, args):
4044
def mimic_install():
@@ -103,7 +107,7 @@ def _get_bundle_sha1(bundle):
103107
return sha1.hexdigest()
104108

105109
@staticmethod
106-
def _get_http_bundle_sha1(url, auth_header):
110+
def _get_http_bundle_sha1(url, auth_header, cert, verify):
107111
"""Download file from URL using HTTP range requests and compute its sha1 checksum."""
108112
sha1 = hashlib.sha1()
109113
headers = auth_header
@@ -112,7 +116,7 @@ def _get_http_bundle_sha1(url, auth_header):
112116
offset = 0
113117
while True:
114118
headers['Range'] = f'bytes={offset}-{offset + range_size - 1}'
115-
r = requests.get(url, headers=headers)
119+
r = requests.get(url, headers=headers, cert=cert, verify=verify)
116120
try:
117121
r.raise_for_status()
118122
sha1.update(r.content)
@@ -130,17 +134,24 @@ def _check_install_requirements(self, source, args):
130134
Check that required headers are set, bundle is accessible (HTTP or locally) and its
131135
checksum matches.
132136
"""
137+
headers = {}
138+
verify = False
139+
if self._mtls:
140+
mtls_conf = MtlsConfig(self.tmp_path)
141+
cert = (mtls_conf.client_cert, mtls_conf.client_key)
142+
else:
143+
cert = None
133144
if 'http-headers' in args:
134-
assert len(args['http-headers']) == 1
135-
136-
[auth_header] = args['http-headers']
137-
key, value = auth_header.split(': ', maxsplit=1)
138-
http_bundle_sha1 = self._get_http_bundle_sha1(source, {key: value})
145+
if len(args['http-headers']) == 1:
146+
[auth_header] = args['http-headers']
147+
headers = dict([auth_header.split(': ', maxsplit=1)])
148+
elif not self._mtls:
149+
raise Exception("No headers in args")
150+
verify = args['tls-no-verify'] is False
151+
if source.startswith("http"):
152+
http_bundle_sha1 = self._get_http_bundle_sha1(source, headers, cert, verify=verify)
139153
assert http_bundle_sha1 == self._get_bundle_sha1(self._bundle)
140154

141-
# assume ssl_verify=false is set in test setup
142-
assert args['tls-no-verify'] is True
143-
144155
else:
145156
# check bundle checksum matches expected checksum
146157
assert self._get_bundle_sha1(source) == self._get_bundle_sha1(self._bundle)
@@ -193,11 +204,15 @@ def BootSlot(self):
193204
parser.add_argument('bundle', help='Expected RAUC bundle')
194205
parser.add_argument('--completed-code', type=int, default=0,
195206
help='Code to emit as D-Bus Completed signal')
207+
parser.add_argument('--tmp-dir', type=str, default=None,
208+
help='Test tmp dir')
209+
parser.add_argument('--mtls', action='store_true',
210+
help='Use MTLS protocols')
196211
args = parser.parse_args()
197212

198213
loop = GLib.MainLoop()
199214
bus = SessionBus()
200-
installer = Installer(args.bundle, args.completed_code)
215+
installer = Installer(args.bundle, args.mtls, args.tmp_dir, args.completed_code)
201216
with bus.publish('de.pengutronix.rauc', ('/', installer)):
202217
print('Interface published')
203218
loop.run()

0 commit comments

Comments
 (0)