Skip to content

Commit bf2ca1e

Browse files
authored
validate crypto command (#390)
* allow to write to tar file. Add a switch to the scionlab-config script to just write the config to a tar file. Fix the --tar switch behavior so that it opens a tar file. * refactor verify functions * add crypto verification functions to AS and ISD. These functions check the validity of the current certificates and keys, as well as the whole chain of TRCs that is stored for the ISD. * update scion-pki so we can use --check-time * simple command to check validity of crypto. For the moment, it retrieves all ISDs and checks their crypto validity. The same for all infrastructure ASes. * update UTs * from review, changes to scionlab-config * cleanup Some functions and a file rename. * reaname noinstall option to onlydownload. The argparse option noinstall is now called onlydownload. * Change verify_ functions' signatures. Requested from review #390, see comments. * move cert/key cardinality check to AS.validate_crypto
1 parent 3849079 commit bf2ca1e

File tree

13 files changed

+271
-85
lines changed

13 files changed

+271
-85
lines changed

scionlab/hostfiles/scionlab-config

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import shlex
3737
import shutil
3838
import subprocess
3939
import sys
40+
import pathlib
4041
import tarfile
4142
import textwrap
4243
import time
@@ -109,13 +110,17 @@ def main(argv):
109110
try:
110111
fetch_info = get_fetch_info(args)
111112
config = fetch_config(fetch_info)
113+
if args.onlydownload:
114+
return write_tarfile(args.onlydownload, config)
115+
112116
if config is _CONFIG_EMPTY:
113117
stop_scion()
114118
elif config is _CONFIG_UNCHANGED:
115119
logging.info('Configuration unchanged (version %s). Nothing to do.',
116120
fetch_info.version)
117121
else:
118-
install_config(args, config)
122+
tgz = tarfile.open(mode='r:gz', fileobj=io.BytesIO(config))
123+
install_config(args, tgz)
119124
confirm_deployed(args)
120125
except TemporaryError as e:
121126
if args.daemon:
@@ -181,10 +186,18 @@ def parse_command_line_args(argv):
181186
'locally modified configuration files. '
182187
'This is enabled by default if --force is not set and '
183188
'the stdin is not a TTY.')
184-
group_config.add_argument('--tar',
185-
type=argparse.FileType('r'),
186-
help='Install configuration from a tar-file already obtained from '
187-
'the SCIONLab coordination service')
189+
withfile_or_onlydownload = group_config.add_mutually_exclusive_group()
190+
withfile_or_onlydownload.add_argument(
191+
'--tar',
192+
type=pathlib.Path,
193+
help='Install configuration from a tar-file already obtained from '
194+
'the SCIONLab coordination service')
195+
withfile_or_onlydownload.add_argument(
196+
'--onlydownload',
197+
metavar='FILE',
198+
type=pathlib.Path,
199+
help='Only obtain the tar-file from the coordinator service, '
200+
'write it to the specified path and do not install.')
188201
group_fetch = parser.add_argument_group('SCIONLab API options')
189202
group_fetch.add_argument('--host-id', help='Host identifier')
190203
group_fetch.add_argument('--host-secret', help='Authentication for host')
@@ -243,7 +256,7 @@ def _load_fetch_info(file, args):
243256
if args.url:
244257
fetch_info = fetch_info._replace(url=args.url)
245258

246-
if args.force:
259+
if args.force or args.onlydownload:
247260
fetch_info = fetch_info._replace(version=None)
248261

249262
return fetch_info
@@ -301,7 +314,7 @@ def fetch_config(fetch_info):
301314
code = conn.getcode()
302315
if code == 200:
303316
response_data = conn.read()
304-
return tarfile.open(mode='r:gz', fileobj=io.BytesIO(response_data))
317+
return response_data
305318
elif code == 204:
306319
return _CONFIG_EMPTY
307320
else:
@@ -321,6 +334,18 @@ def fetch_config(fetch_info):
321334
raise TemporaryError(error_msg % (fetch_info.url, e))
322335

323336

337+
def write_tarfile(filepath, tarbytes):
338+
if tarbytes is _CONFIG_EMPTY:
339+
logging.warn("will create an empty tar file")
340+
buff = io.BytesIO()
341+
tt = tarfile.open(mode='w:gz', fileobj=buff)
342+
tt.close()
343+
tarbytes = buff.getvalue()
344+
345+
with open(filepath, 'wb') as f:
346+
f.write(tarbytes)
347+
348+
324349
def confirm_deployed(args):
325350
"""
326351
Inform the SCIONLab coordinator of the currently installed version of the configuration. This
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Copyright 2021 ETH Zurich
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from django.core.management.base import BaseCommand
16+
17+
from scionlab.models.core import ISD
18+
from scionlab.scion.pkicommand import ScionPkiError
19+
20+
21+
class Command(BaseCommand):
22+
help = 'Checks all certificates of all infrastructure ASes for their validity'
23+
24+
def handle(self, *args, **kwargs):
25+
failed_isds = []
26+
failed_ases = []
27+
isds = ISD.objects.all()
28+
for isd in isds:
29+
try:
30+
num = isd.validate_crypto()
31+
print(f'ISD {isd.isd_id}: {isd.label} ; {num} TRCs.')
32+
except ScionPkiError as ex:
33+
failed_isds.append(isd)
34+
print(f'---------------------------\n'
35+
f'ERROR: failed to verify ISD {isd.isd_id}: {ex}'
36+
f'\n-----------------------------')
37+
continue
38+
ases = isd.ases.filter(owner=None)
39+
for as_ in ases:
40+
try:
41+
num = as_.validate_crypto()
42+
print(f'AS {as_.as_id}, core: {as_.is_core} ; {num} certs')
43+
except ScionPkiError as ex:
44+
failed_ases.append(as_)
45+
print(f'failed AS: {as_.as_id}: {ex}')
46+
47+
print(f'summary; {len(failed_isds)} failed ISDs: '
48+
f'{[isd.isd_id for isd in failed_isds]}')
49+
print(f'summary; {len(failed_ases)} failed ASes: '
50+
f'{[as_.as_id for as_ in failed_ases]}')

scionlab/models/core.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@
3333
from scionlab.models.user import User
3434
from scionlab.models.pki import Key, Certificate
3535
from scionlab.scion import as_ids
36+
from scionlab.scion.certs import verify_certificate_valid, verify_cp_as_chain
37+
from scionlab.scion.keys import verify_key
38+
from scionlab.scion.trcs import decode_trc, verify_trcs
39+
from scionlab.scion.pkicommand import ScionPkiError
3640
from scionlab.util.django import value_set
3741
from scionlab.defines import (
3842
MAX_INTERFACE_ID,
@@ -108,6 +112,16 @@ def update_trc_and_certificates(self):
108112

109113
return self.trcs.create()
110114

115+
def validate_crypto(self):
116+
"""
117+
checks that the crypto material for this object is correct,
118+
namely that the trc chain is valid.
119+
Returns the number of valid trcs in this ISD.
120+
"""
121+
ts = [decode_trc(t.trc) for t in self.trcs.order_by('serial_version')]
122+
verify_trcs(ts[0], *ts)
123+
return len(ts)
124+
111125

112126
class ASManager(models.Manager):
113127
def create(self, isd, as_id, is_core=False, label=None, mtu=None, owner=None,
@@ -287,6 +301,33 @@ def keys_latest(self):
287301
version__gte=latest_version)
288302
return keys
289303

304+
def validate_crypto(self):
305+
"""
306+
checks that the crypto material for this object is correct, by obtaining the
307+
latests certificates and keys and checking them against the latest TRC.
308+
Returns the number of certificates (with keys) that passed the check.
309+
"""
310+
trc = decode_trc(self.isd.trcs.latest().trc)
311+
certs = self.certificates_latest()
312+
cert_map = {}
313+
for cert in certs:
314+
cert_map[cert.usage()] = cert.certificate
315+
verify_certificate_valid(cert.certificate, cert.usage())
316+
if cert.usage() == Key.CP_AS:
317+
verify_cp_as_chain(cert.format_certfile(), trc) # chain
318+
keys = self.keys_latest()
319+
for key in keys:
320+
if key.usage not in cert_map:
321+
raise ScionPkiError(f'no corresponding cert for key {key.filename()}')
322+
verify_key(key.key, cert_map[key.usage])
323+
if len(keys) != len(certs):
324+
raise ScionPkiError(f'different number of keys ({len(keys)}) than certs ({len(certs)})')
325+
if (self.is_core and len(keys) != 5) or (not self.is_core and len(keys) != 1):
326+
raise ScionPkiError(
327+
f'AS {self.as_id}, core:{self.is_core}: '
328+
f'invalid number of keys/certs {len(keys)}')
329+
return len(keys)
330+
290331
def generate_keys(self, not_before=None):
291332
Key.objects.create_all_keys(self, not_before=not_before)
292333

scionlab/scion/certs.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,11 @@
2626
from cryptography.hazmat.primitives import serialization, hashes
2727
from cryptography.hazmat.primitives.asymmetric import ec
2828
from datetime import datetime
29+
from tempfile import NamedTemporaryFile
2930
from typing import List, Tuple, Optional, NamedTuple
3031

32+
from scionlab.scion.pkicommand import run_scion_pki
33+
3134

3235
OID_ISD_AS = ObjectIdentifier('1.3.6.1.4.1.55324.1.2.1')
3336
OID_SENSITIVE_KEY = ObjectIdentifier('1.3.6.1.4.1.55324.1.3.1')
@@ -59,6 +62,35 @@ def decode_certificate(pem: str) -> x509.Certificate:
5962
return x509.load_pem_x509_certificate(pem.encode("ascii"))
6063

6164

65+
def verify_certificate_valid(cert: str, cert_usage: str):
66+
"""
67+
Verifies that the certificate's fields are valid for that type.
68+
The certificate is passed as a PEM string.
69+
This function does not verify the trust chain
70+
(see also verify_cp_as_chain).
71+
"""
72+
with NamedTemporaryFile('w', suffix=".crt") as f:
73+
f.write(cert)
74+
f.flush()
75+
_run_scion_pki_certificate('validate', '--type', cert_usage, '--check-time', f.name)
76+
77+
78+
def verify_cp_as_chain(cert: str, trc: bytes):
79+
"""
80+
Verify that the certificate is valid, using the last TRC as anchor.
81+
The certificate is passed as a PEM string.
82+
The TRC is passed as bytes, basee 64 format.
83+
Raises ScionPkiError if the certificate is not valid.
84+
"""
85+
with NamedTemporaryFile(mode='wb', suffix=".trc") as trc_file,\
86+
NamedTemporaryFile(mode='wt', suffix=".pem") as cert_file:
87+
files = [trc_file, cert_file]
88+
for f, value in zip(files, [trc, cert]):
89+
f.write(value)
90+
f.flush()
91+
_run_scion_pki_certificate('verify', '--trc', trc_file.name, cert_file.name)
92+
93+
6294
def generate_voting_sensitive_certificate(subject_id: str,
6395
subject_key: ec.EllipticCurvePrivateKey,
6496
not_before: datetime,
@@ -250,3 +282,7 @@ def _build_extensions_as(subject_key: ec.EllipticCurvePrivateKey,
250282
x509.ExtendedKeyUsageOID.CLIENT_AUTH,
251283
x509.ExtendedKeyUsageOID.TIME_STAMPING]),
252284
critical=False)]
285+
286+
287+
def _run_scion_pki_certificate(*args, cwd=None, check=True):
288+
return run_scion_pki('certificate', *args, cwd=cwd, check=check)

scionlab/scion/keys.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,15 @@
1818
========================================================================
1919
"""
2020

21+
import contextlib
22+
2123
from cryptography.hazmat.primitives.asymmetric import ec
2224
from cryptography.hazmat.primitives import serialization
25+
from tempfile import NamedTemporaryFile
2326
from typing import cast
2427

28+
from scionlab.scion.pkicommand import run_scion_pki
29+
2530

2631
def generate_key() -> ec.EllipticCurvePrivateKeyWithSerialization:
2732
""" Generate an elliptic curve private key """
@@ -43,3 +48,24 @@ def encode_key(key: ec.EllipticCurvePrivateKeyWithSerialization) -> str:
4348
def decode_key(pem: str) -> ec.EllipticCurvePrivateKey:
4449
""" Returns an EllipticCurve key from its PEM encoding """
4550
return serialization.load_pem_private_key(pem.encode("ascii"), password=None)
51+
52+
53+
def verify_key(key: str, cert: str):
54+
"""
55+
Verify that the certificate is valid, using the last TRC as anchor.
56+
The key is passed as a PEM string.
57+
The certificate is passed as a PEM string.
58+
Raises ScionPkiError if the certificate is not valid.
59+
"""
60+
with contextlib.ExitStack() as stack:
61+
key_file = stack.enter_context(NamedTemporaryFile(mode='wt', suffix=".key"))
62+
cert_file = stack.enter_context(NamedTemporaryFile(mode='wt', suffix=".crt"))
63+
files = [key_file, cert_file]
64+
for f, value in zip(files, [key, cert]):
65+
f.write(value)
66+
f.flush()
67+
_run_scion_pki_key('match', 'certificate', key_file.name, cert_file.name)
68+
69+
70+
def _run_scion_pki_key(*args, cwd=None, check=True):
71+
return run_scion_pki('key', *args, cwd=cwd, check=check)

scionlab/scion/pkicommand.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Copyright 2021 ETH Zurich
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""
16+
:mod:`scionlab.scion.util` --- scion-pki execution
17+
==================================================
18+
"""
19+
20+
import subprocess
21+
22+
from django.conf import settings
23+
24+
25+
def run_scion_pki(*args, cwd=None, check=True):
26+
try:
27+
return subprocess.run([settings.SCION_PKI_COMMAND, *args],
28+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
29+
encoding='utf-8',
30+
check=check,
31+
cwd=cwd)
32+
except subprocess.CalledProcessError as e:
33+
raise ScionPkiError(e) from None
34+
35+
36+
class ScionPkiError(subprocess.CalledProcessError):
37+
"""
38+
Wrapper for CalledProcessError (raised by subprocess.run on returncode != 0 if check=True),
39+
that includes the process output (stdout) in the __str__.
40+
"""
41+
def __init__(self, e):
42+
if isinstance(e, subprocess.CalledProcessError):
43+
super().__init__(e.returncode, e.cmd, e.output, e.stderr)
44+
else:
45+
super().__init__(returncode=0, cmd='<<internal>>', output=str(e))
46+
47+
def __str__(self):
48+
s = super().__str__()
49+
if self.output:
50+
s += "\nOutput:\n"
51+
s += self.output
52+
return s

0 commit comments

Comments
 (0)