diff options
author | Gregor Kleen <gkleen@yggdrasil.li> | 2022-11-07 20:51:39 +0100 |
---|---|---|
committer | Gregor Kleen <gkleen@yggdrasil.li> | 2022-11-07 20:51:39 +0100 |
commit | 0e9f1e85cd8c6f9d546ef88e971043b909017170 (patch) | |
tree | 5cb4d14df7594ef123f20d82cb2ec423b6bca744 /tools | |
parent | f563ddece04adfd8d80d4e984405f5c70a6c94f3 (diff) | |
download | nixos-0e9f1e85cd8c6f9d546ef88e971043b909017170.tar nixos-0e9f1e85cd8c6f9d546ef88e971043b909017170.tar.gz nixos-0e9f1e85cd8c6f9d546ef88e971043b909017170.tar.bz2 nixos-0e9f1e85cd8c6f9d546ef88e971043b909017170.tar.xz nixos-0e9f1e85cd8c6f9d546ef88e971043b909017170.zip |
...
Diffstat (limited to 'tools')
-rw-r--r-- | tools/ca/ca/__main__.py | 568 | ||||
-rw-r--r-- | tools/ca/setup.py | 10 |
2 files changed, 578 insertions, 0 deletions
diff --git a/tools/ca/ca/__main__.py b/tools/ca/ca/__main__.py new file mode 100644 index 00000000..e3e4bbe6 --- /dev/null +++ b/tools/ca/ca/__main__.py | |||
@@ -0,0 +1,568 @@ | |||
1 | import sys, os | ||
2 | |||
3 | import logging | ||
4 | import argparse | ||
5 | |||
6 | from inspect import signature | ||
7 | |||
8 | from enum import Enum, auto | ||
9 | from contextlib import contextmanager | ||
10 | |||
11 | from cryptography import __version__ as cryptography_version | ||
12 | from cryptography.hazmat.backends import openssl | ||
13 | from cryptography import x509 | ||
14 | from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID, ExtensionOID | ||
15 | from cryptography.hazmat.primitives import serialization, hashes | ||
16 | from cryptography.hazmat.primitives.serialization import PrivateFormat, pkcs12 | ||
17 | from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey | ||
18 | from cryptography.hazmat.primitives.asymmetric.ed448 import Ed448PrivateKey | ||
19 | from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey | ||
20 | from cryptography.hazmat.primitives.asymmetric import rsa | ||
21 | from pathlib import Path | ||
22 | from atomicwrites import atomic_write | ||
23 | from fqdn import FQDN | ||
24 | from datetime import datetime, timedelta, timezone | ||
25 | from math import ceil, ldexp | ||
26 | import re | ||
27 | from getpass import getpass | ||
28 | from itertools import count | ||
29 | from tempfile import TemporaryFile | ||
30 | import subprocess | ||
31 | import json | ||
32 | from leapseconddata import LeapSecondData | ||
33 | |||
34 | |||
35 | class KeyType(Enum): | ||
36 | ED448 = 'ed448' | ||
37 | ED25519 = 'ed25519' | ||
38 | RSA4096 = 'rsa4096' | ||
39 | RSA2048 = 'rsa2048' | ||
40 | |||
41 | def generate(self): | ||
42 | match self: | ||
43 | case KeyType.ED448: | ||
44 | return Ed448PrivateKey.generate() | ||
45 | case KeyType.ED25519: | ||
46 | return Ed25519PrivateKey.generate() | ||
47 | case KeyType.RSA4096: | ||
48 | return rsa.generate_private_key( | ||
49 | public_exponent = 65537, | ||
50 | key_size = 4096, | ||
51 | ) | ||
52 | case KeyType.RSA2048: | ||
53 | return rsa.generate_private_key( | ||
54 | public_exponent = 65537, | ||
55 | key_size = 2048, | ||
56 | ) | ||
57 | |||
58 | def aligned(self, key): | ||
59 | match self: | ||
60 | case KeyType.ED448: | ||
61 | return isinstance(key, Ed448PrivateKey) | ||
62 | case KeyType.ED25519: | ||
63 | return isinstance(key, Ed25519PrivateKey) | ||
64 | case KeyType.RSA4096: | ||
65 | return isinstance(key, RSAPrivateKey) and key.key_size == 4096 | ||
66 | case KeyType.RSA2048: | ||
67 | return isinstance(key, RSAPrivateKey) and key.key_size == 2048 | ||
68 | |||
69 | def __str__(self): | ||
70 | return self.value | ||
71 | |||
72 | @classmethod | ||
73 | def from_string(cls, s): | ||
74 | try: | ||
75 | return cls(s) | ||
76 | except KeyError: | ||
77 | raise ValueError() | ||
78 | |||
79 | class ValidFQDN(FQDN): | ||
80 | def __init__(self, *args, **kwds): | ||
81 | super().__init__(*args, **kwds) | ||
82 | |||
83 | if not self.is_valid: | ||
84 | raise ValueError(f'‘{self}’ is not valid') | ||
85 | |||
86 | def duration(inp_str): | ||
87 | delta = timedelta() | ||
88 | |||
89 | item_re = re.compile(r'\W*(?P<value>\d+)\W*(?P<unit>(?i:d|h|m(?!s)|s|ms|µs))') | ||
90 | |||
91 | match = item_re.match(inp_str) | ||
92 | while match: | ||
93 | val = int(match.group('value')) | ||
94 | unit = match.group('unit').lower() | ||
95 | |||
96 | if unit == 'd': | ||
97 | delta += timedelta(days=val) | ||
98 | elif unit == 'h': | ||
99 | delta += timedelta(hours=val) | ||
100 | elif unit == 'm': | ||
101 | delta += timedelta(minutes=val) | ||
102 | elif unit == 's': | ||
103 | delta += timedelta(seconds=val) | ||
104 | elif unit == 'ms': | ||
105 | delta += timedelta(milliseconds=val) | ||
106 | elif unit == 'µs' or unit == 'us': | ||
107 | delta += timedelta(microseconds=val) | ||
108 | else: | ||
109 | raise ValueError(f'Unknown time unit ‘{unit:s}’') | ||
110 | |||
111 | inp_str = inp_str[match.end():] | ||
112 | match = item_re.match(inp_str) | ||
113 | else: | ||
114 | if re.match('\w', inp_str): | ||
115 | raise ValueError(f'Parsing of duration resulted in leftovers: ‘{inp_str:s}’') | ||
116 | |||
117 | return delta | ||
118 | |||
119 | @contextmanager | ||
120 | def umask(desired_umask): | ||
121 | """ A little helper to safely set and restore umask(2). """ | ||
122 | try: | ||
123 | prev_umask = os.umask(0) | ||
124 | os.umask(prev_umask | desired_umask) | ||
125 | yield | ||
126 | finally: | ||
127 | os.umask(prev_umask) | ||
128 | |||
129 | class BooleanAction(argparse.Action): | ||
130 | def __init__(self, option_strings, dest, nargs=None, **kwargs): | ||
131 | super(BooleanAction, self).__init__(option_strings, dest, nargs=0, **kwargs) | ||
132 | |||
133 | def __call__(self, parser, namespace, values, option_string=None): | ||
134 | setattr(namespace, self.dest, False if option_string.startswith('--no') else True) | ||
135 | |||
136 | |||
137 | def load_key(keyfile, prompt='CA private key password: '): | ||
138 | key = None | ||
139 | with open(keyfile, 'rb') as f: | ||
140 | is_sops = False | ||
141 | try: | ||
142 | sops_json = json.load(f) | ||
143 | is_sops = 'sops' in sops_json | ||
144 | except json.JSONDecodeError: | ||
145 | pass | ||
146 | |||
147 | f.seek(0) | ||
148 | |||
149 | if not is_sops: | ||
150 | try: | ||
151 | key = serialization.load_pem_private_key(f.read(), password=None) | ||
152 | except TypeError: | ||
153 | pw = getpass(prompt=prompt) | ||
154 | key = serialization.load_pem_private_key(f.read(), password=bytes(pw, sys.stdin.encoding)) | ||
155 | else: | ||
156 | cmd = ['sops', '-d', f'/dev/fd/{f.fileno()}'] | ||
157 | with subprocess.Popen(cmd, stdout=subprocess.PIPE, pass_fds=(f.fileno(),)) as proc: | ||
158 | key = serialization.load_pem_private_key(proc.stdout.read(), password=None) | ||
159 | ret = proc.wait() | ||
160 | if ret != 0: | ||
161 | raise subprocess.CalledProcessErrror(ret, cmd) | ||
162 | |||
163 | return key | ||
164 | |||
165 | def mv_bak(path): | ||
166 | global logger | ||
167 | |||
168 | bak_path = path.parent / f'{path.name}.bak' | ||
169 | for n in count(2): | ||
170 | if not bak_path.exists(): | ||
171 | break | ||
172 | bak_path = path.parent / f'{path.name}.bak{n}' | ||
173 | |||
174 | logger.warn('Renaming ‘%s’ to ‘%s’...', path, bak_path) | ||
175 | path.rename(bak_path) | ||
176 | |||
177 | def tai64nint(dt): | ||
178 | global leapsecond_data | ||
179 | |||
180 | have_data = False | ||
181 | try: | ||
182 | have_data = bool(leapsecond_data) | ||
183 | except NameError: | ||
184 | pass | ||
185 | |||
186 | if not have_data: | ||
187 | leapsecond_data = LeapSecondData.from_file(Path(os.getenv('LEAPSECONDS_FILE'))) | ||
188 | |||
189 | tai_dt = leapsecond_data.to_tai(dt) | ||
190 | seconds = int(tai_dt.timestamp()) | ||
191 | nanoseconds = int((tai_dt.timestamp() - seconds) / 1e-9) | ||
192 | seconds += int(ldexp(1, 62)) | ||
193 | return seconds << 32 | nanoseconds | ||
194 | |||
195 | def write_genkey(key_type, sops, keyfile): | ||
196 | if keyfile.exists(): | ||
197 | raise ValueError(f'Keyfile exists: {keyfile}') | ||
198 | |||
199 | key = None | ||
200 | |||
201 | def genkey(fh): | ||
202 | nonlocal key, key_type | ||
203 | |||
204 | logger.debug('Generating new privkey...') | ||
205 | key = key_type.generate() | ||
206 | priv_bytes = key.private_bytes(encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption()) | ||
207 | fh.write(priv_bytes) | ||
208 | |||
209 | if not sops: | ||
210 | with umask(0o0177), atomic_write(keyfile, overwrite=False, mode='wb') as fh: | ||
211 | logger.info('Writing new privkey to ‘%s’...', keyfile) | ||
212 | genkey(fh) | ||
213 | logger.debug('Adjusting permissions for ‘%s’...', keyfile) | ||
214 | os.chmod(keyfile, 0o0400) | ||
215 | else: | ||
216 | with TemporaryFile(mode='wb') as tf: | ||
217 | genkey(tf) | ||
218 | tf.seek(0) | ||
219 | |||
220 | with umask(0o0177), atomic_write(keyfile, overwrite=False, mode='wb') as fh: | ||
221 | logger.info('Encrypting new privkey to ‘%s’...', keyfile) | ||
222 | subprocess.run(['sops', '-e', f'/dev/fd/{tf.fileno()}'], stdout=fh, pass_fds=(tf.fileno(),), check=True) | ||
223 | logger.debug('Adjusting permissions for ‘%s’...', keyfile) | ||
224 | os.chmod(keyfile, 0o0400) | ||
225 | |||
226 | return key | ||
227 | |||
228 | def initca(ca_cert, ca_key, key_type, subject, clock_skew, validity, sops): | ||
229 | global logger | ||
230 | |||
231 | key = None | ||
232 | try: | ||
233 | key = load_key(ca_key) | ||
234 | logger.info('Successfully loaded privkey from ‘%s’', ca_key) | ||
235 | |||
236 | if not key_type.aligned(key): | ||
237 | logger.warn('Private key ‘%s’ does not align with requested type %s', ca_key, key_type) | ||
238 | |||
239 | try: | ||
240 | mv_bak(ca_key) | ||
241 | except FileNotFoundError: | ||
242 | pass | ||
243 | try: | ||
244 | mv_bak(ca_cert) | ||
245 | except FileNotFoundError: | ||
246 | pass | ||
247 | |||
248 | raise FileNotFoundError(f'Key does not align with requested type: {ca_key}') | ||
249 | except FileNotFoundError: | ||
250 | key = write_genkey(key_type, sops, ca_key) | ||
251 | |||
252 | cert = None | ||
253 | try: | ||
254 | with open(ca_cert, 'rb') as fh: | ||
255 | cert = x509.load_pem_x509_certificate(fh.read()) | ||
256 | logger.info('Successfully loaded certificate from ‘%s’', ca_cert) | ||
257 | except FileNotFoundError: | ||
258 | logger.debug('Generating new certificate...') | ||
259 | |||
260 | now = datetime.utcnow() | ||
261 | name = x509.Name([ | ||
262 | x509.NameAttribute(NameOID.COMMON_NAME, subject.relative) | ||
263 | ]) | ||
264 | |||
265 | cert = x509.CertificateBuilder().subject_name( | ||
266 | name | ||
267 | ).public_key( | ||
268 | key.public_key() | ||
269 | ).serial_number( | ||
270 | x509.random_serial_number() | ||
271 | ).not_valid_before( | ||
272 | now - clock_skew | ||
273 | ).not_valid_after( | ||
274 | now + validity | ||
275 | ).issuer_name( | ||
276 | name | ||
277 | ).add_extension( | ||
278 | x509.AuthorityKeyIdentifier.from_issuer_public_key(key.public_key()), | ||
279 | False | ||
280 | ).add_extension( | ||
281 | x509.SubjectKeyIdentifier.from_public_key(key.public_key()), | ||
282 | False | ||
283 | ).add_extension( | ||
284 | x509.KeyUsage(digital_signature=True, content_commitment=False, key_encipherment=False, data_encipherment=False, key_agreement=False, key_cert_sign=True, crl_sign=True, encipher_only=False, decipher_only=False), | ||
285 | True | ||
286 | ).add_extension( | ||
287 | x509.BasicConstraints(ca=True, path_length=None), | ||
288 | True | ||
289 | ).sign(key, None if isinstance(key, Ed25519PrivateKey) or isinstance(key, Ed448PrivateKey) else hashes.SHA512()) | ||
290 | |||
291 | with umask(0o0133), atomic_write(ca_cert, overwrite=False, mode='wb') as cf: | ||
292 | logger.info('Writing new certificate to ‘%s’...', ca_cert) | ||
293 | cf.write(cert.public_bytes(serialization.Encoding.PEM)) | ||
294 | logger.debug('Adjusting permissions for ‘%s’...', ca_cert) | ||
295 | os.chmod(ca_cert, 0o0444) | ||
296 | |||
297 | def signcsr(ca_cert, ca_key, clock_skew, validity, subject, alternative_name, ignore_alternative_names, csr, output): | ||
298 | csr_bytes = None | ||
299 | try: | ||
300 | csr_bytes = csr.read() | ||
301 | except AttributeError: | ||
302 | csr_bytes = csr | ||
303 | |||
304 | csr = x509.load_pem_x509_csr(csr_bytes) | ||
305 | if not subject: | ||
306 | common_name_attrs = csr.subject.get_attributes_for_oid(NameOID.COMMON_NAME) | ||
307 | if len(common_name_attrs) != 1: | ||
308 | raise InvalidParamsError('Invalid name structure in CSR') | ||
309 | subject = common_name_attrs[0].value.lower() | ||
310 | logger.warn('Using subject common name from csr: %s', subject) | ||
311 | name = x509.Name([ | ||
312 | x509.NameAttribute(NameOID.COMMON_NAME, subject) | ||
313 | ]) | ||
314 | |||
315 | if not ignore_alternative_names: | ||
316 | ext = csr.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME) | ||
317 | csr_alt_names = ext.value.get_values_for_type(x509.DNSName) | ||
318 | logger.warn('Using alternative names from csr: %s', csr_alt_names) | ||
319 | alternative_name = list(set(alternative_name) | set(csr_alt_names)) | ||
320 | |||
321 | ca_key = load_key(ca_key) | ||
322 | with open(ca_cert, 'rb') as fh: | ||
323 | ca_cert = x509.load_pem_x509_certificate(fh.read()) | ||
324 | |||
325 | now = datetime.now(tz=timezone.utc) | ||
326 | cert = x509.CertificateBuilder().subject_name( | ||
327 | name | ||
328 | ).public_key( | ||
329 | csr.public_key() | ||
330 | ).serial_number( | ||
331 | (tai64nint(now) << 24) | (x509.random_serial_number() & int(ldexp(1, 24) - 1)) | ||
332 | ).not_valid_before( | ||
333 | now - clock_skew | ||
334 | ).not_valid_after( | ||
335 | now + validity | ||
336 | ).issuer_name( | ||
337 | ca_cert.subject | ||
338 | ).add_extension( | ||
339 | x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_cert.public_key()), | ||
340 | False | ||
341 | ).add_extension( | ||
342 | x509.SubjectKeyIdentifier.from_public_key(csr.public_key()), | ||
343 | False | ||
344 | ).add_extension( | ||
345 | x509.KeyUsage(digital_signature=True, content_commitment=True, key_encipherment=True, data_encipherment=False, key_agreement=False, key_cert_sign=False, crl_sign=False, encipher_only=False, decipher_only=False), | ||
346 | True | ||
347 | ).add_extension( | ||
348 | x509.BasicConstraints(ca=False, path_length=None), | ||
349 | True | ||
350 | ).add_extension( | ||
351 | x509.ExtendedKeyUsage([ExtendedKeyUsageOID.CLIENT_AUTH]), | ||
352 | False | ||
353 | ) | ||
354 | |||
355 | if alternative_name: | ||
356 | cert = cert.add_extension( | ||
357 | x509.SubjectAlternativeName( | ||
358 | list(map(x509.DNSName, alternative_name)) | ||
359 | ), | ||
360 | False | ||
361 | ) | ||
362 | |||
363 | cert = cert.sign(ca_key, None if isinstance(ca_key, Ed25519PrivateKey) or isinstance(ca_key, Ed448PrivateKey) else hashes.SHA256()) | ||
364 | |||
365 | output = output.with_suffix('.crt') | ||
366 | |||
367 | try: | ||
368 | mv_bak(output) | ||
369 | except FileNotFoundError: | ||
370 | pass | ||
371 | with umask(0o0133), atomic_write(output, overwrite=False, mode='wb') as cf: | ||
372 | logger.info('Writing new certificate to ‘%s’...', output) | ||
373 | cf.write(cert.public_bytes(serialization.Encoding.PEM)) | ||
374 | logger.debug('Adjusting permissions for ‘%s’...', output) | ||
375 | os.chmod(output, 0o0444) | ||
376 | |||
377 | def new_client(ca_cert, ca_key, key_type, clock_skew, validity, subject, alternative_name, sops, output): | ||
378 | key_file = output.with_suffix('.key') | ||
379 | cert_file = output.with_suffix('.crt') | ||
380 | |||
381 | key = None | ||
382 | try: | ||
383 | key = load_key(key_file) | ||
384 | logger.info('Successfully loaded privkey from ‘%s’', key_file) | ||
385 | |||
386 | if not key_type.aligned(key): | ||
387 | logger.warn('Private key ‘%s’ does not align with requested type %s', key_file, key_type) | ||
388 | |||
389 | try: | ||
390 | mv_bak(key_file) | ||
391 | except FileNotFoundError: | ||
392 | pass | ||
393 | try: | ||
394 | mv_bak(cert_file) | ||
395 | except FileNotFoundError: | ||
396 | pass | ||
397 | |||
398 | raise FileNotFoundError(f'Key does not align with requested type: {key_file}') | ||
399 | except FileNotFoundError: | ||
400 | key = write_genkey(key_type, sops, key_file) | ||
401 | |||
402 | csr = x509.CertificateSigningRequestBuilder().subject_name(x509.Name([ | ||
403 | x509.NameAttribute(NameOID.COMMON_NAME, subject) | ||
404 | ])) | ||
405 | if alternative_name: | ||
406 | csr = csr.add_extension( | ||
407 | x509.SubjectAlternativeName( | ||
408 | list(map(x509.DNSName, alternative_name)) | ||
409 | ), | ||
410 | False | ||
411 | ) | ||
412 | |||
413 | return signcsr( | ||
414 | ca_cert=ca_cert, | ||
415 | ca_key=ca_key, | ||
416 | clock_skew=clock_skew, | ||
417 | validity=validity, | ||
418 | subject=None, | ||
419 | alternative_name=[], | ||
420 | ignore_alternative_names=False, | ||
421 | output=cert_file, | ||
422 | csr=csr.sign( | ||
423 | key, | ||
424 | None if isinstance(key, Ed25519PrivateKey) or isinstance(key, Ed448PrivateKey) else hashes.SHA256(), | ||
425 | ).public_bytes(serialization.Encoding.PEM) | ||
426 | ) | ||
427 | |||
428 | def to_pkcs12(random_password, filename, output): | ||
429 | key_file = filename.with_suffix('.key') | ||
430 | cert_file = filename.with_suffix('.crt') | ||
431 | |||
432 | if not output: | ||
433 | output = filename.with_suffix('.p12') | ||
434 | |||
435 | key = load_key(key_file) | ||
436 | logger.info('Successfully loaded privkey from ‘%s’', key_file) | ||
437 | cert = None | ||
438 | with open(cert_file, mode='rb') as fh: | ||
439 | cert = x509.load_pem_x509_certificate(fh.read()) | ||
440 | logger.info('Successfully loaded certificate from ‘%s’', cert_file) | ||
441 | |||
442 | with umask(0o0177), atomic_write(output, overwrite=False, mode='wb') as fh: | ||
443 | logger.info('Writing to ‘%s’...', output) | ||
444 | common_name_attrs = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME) | ||
445 | if len(common_name_attrs) != 1: | ||
446 | raise InvalidParamsError('Invalid name structure in cert') | ||
447 | subject = common_name_attrs[0].value.lower() | ||
448 | |||
449 | pw = None | ||
450 | if not random_password: | ||
451 | pw2 = None | ||
452 | while not pw2 or pw2 != pw: | ||
453 | pw = getpass(prompt='Password: ') | ||
454 | if not pw: | ||
455 | pw = None | ||
456 | break | ||
457 | else: | ||
458 | pw2 = getpass(prompt='Repeat password: ') | ||
459 | else: | ||
460 | from xkcdpass import xkcd_password as xp | ||
461 | ws = xp.generate_wordlist(wordfile=xp.locate_wordfile()) | ||
462 | pw = xp.generate_xkcdpassword(ws, numwords=12) | ||
463 | print(f'Password: {pw}', file=sys.stderr) | ||
464 | |||
465 | encryption = None | ||
466 | if pw: | ||
467 | encryption = PrivateFormat.PKCS12.encryption_builder().kdf_rounds( | ||
468 | 500000 | ||
469 | ).key_cert_algorithm( | ||
470 | pkcs12.PBES.PBESv2SHA256AndAES256CBC | ||
471 | ).hmac_hash( | ||
472 | hashes.SHA256() | ||
473 | ).build(bytes(pw, 'utf-8')) | ||
474 | fh.write(pkcs12.serialize_key_and_certificates( | ||
475 | bytes(subject, 'utf-8'), | ||
476 | key, | ||
477 | cert, | ||
478 | None, | ||
479 | encryption, | ||
480 | )) | ||
481 | logger.debug('Adjusting permissions for ‘%s’...', output) | ||
482 | os.chmod(output, 0o0400) | ||
483 | |||
484 | |||
485 | def main(): | ||
486 | global logger | ||
487 | logger = logging.getLogger(__name__) | ||
488 | console_handler = logging.StreamHandler() | ||
489 | console_handler.setFormatter( logging.Formatter('[%(levelname)s](%(name)s): %(message)s') ) | ||
490 | if sys.stderr.isatty(): | ||
491 | console_handler.setFormatter( logging.Formatter('%(asctime)s [%(levelname)s](%(name)s): %(message)s') ) | ||
492 | logger.addHandler(console_handler) | ||
493 | |||
494 | # log uncaught exceptions | ||
495 | def log_exceptions(type, value, tb): | ||
496 | global logger | ||
497 | |||
498 | logger.error(value) | ||
499 | sys.__excepthook__(type, value, tb) # calls default excepthook | ||
500 | |||
501 | sys.excepthook = log_exceptions | ||
502 | |||
503 | |||
504 | parser = argparse.ArgumentParser(prog='ca', formatter_class=argparse.ArgumentDefaultsHelpFormatter) | ||
505 | parser.add_argument('--verbosity', dest='log_level', action='append', type=int) | ||
506 | parser.add_argument('--verbose', '-v', dest='log_level', action='append_const', const=1) | ||
507 | parser.add_argument('--quiet', '-q', dest='log_level', action='append_const', const=-1) | ||
508 | subparsers = parser.add_subparsers(help='Subcommands', required=True) | ||
509 | |||
510 | subparser = subparsers.add_parser('init', aliases=['initca', 'init-ca', 'ca'], formatter_class=argparse.ArgumentDefaultsHelpFormatter) | ||
511 | subparser.add_argument('--ca-cert', type=Path, default=Path('ca.crt')) | ||
512 | subparser.add_argument('--ca-key', type=Path, default=Path('ca.key')) | ||
513 | subparser.add_argument('--key-type', type=KeyType.from_string, choices=list(KeyType), default=KeyType.ED448.value) | ||
514 | subparser.add_argument('--clock-skew', metavar='DURATION', type=duration, default=timedelta(minutes=5)) | ||
515 | subparser.add_argument('--validity', metavar='DURATION', type=duration, default=timedelta(days=ceil(365.2425*10))) | ||
516 | subparser.add_argument('--sops', '--no-sops', action=BooleanAction, default=True) | ||
517 | subparser.add_argument('--subject', metavar='FQDN', type=ValidFQDN, required=True) | ||
518 | subparser.set_defaults(cmd=initca) | ||
519 | |||
520 | subparser = subparsers.add_parser('sign', aliases=['signcsr', 'sign-csr'], formatter_class=argparse.ArgumentDefaultsHelpFormatter) | ||
521 | subparser.add_argument('--ca-cert', type=Path, default=Path('ca.crt')) | ||
522 | subparser.add_argument('--ca-key', type=Path, default=Path('ca.key')) | ||
523 | subparser.add_argument('--clock-skew', metavar='DURATION', type=duration, default=timedelta(minutes=5)) | ||
524 | subparser.add_argument('--validity', metavar='DURATION', type=duration, default=timedelta(days=ceil(365.2425*10))) | ||
525 | subparser.add_argument('--subject', metavar='CN', type=str, required=False) | ||
526 | subparser.add_argument('--ignore-alternative-names', '--no-ignore-alternative-names', action=BooleanAction, default=True) | ||
527 | subparser.add_argument('--alternative-name', metavar='CN', type=str, action='append') | ||
528 | subparser.add_argument('--output', type=Path, required=True) | ||
529 | subparser.add_argument('csr', metavar='FILE', type=argparse.FileType(mode='rb')) | ||
530 | subparser.set_defaults(cmd=signcsr) | ||
531 | |||
532 | subparser = subparsers.add_parser('new-client', aliases=['new', 'new-client', 'client'], formatter_class=argparse.ArgumentDefaultsHelpFormatter) | ||
533 | subparser.add_argument('--ca-cert', type=Path, default=Path('ca.crt')) | ||
534 | subparser.add_argument('--ca-key', type=Path, default=Path('ca.key')) | ||
535 | subparser.add_argument('--key-type', type=KeyType.from_string, choices=list(KeyType), default=KeyType.ED25519.value) | ||
536 | subparser.add_argument('--clock-skew', metavar='DURATION', type=duration, default=timedelta(minutes=5)) | ||
537 | subparser.add_argument('--validity', metavar='DURATION', type=duration, default=timedelta(days=ceil(365.2425*10))) | ||
538 | subparser.add_argument('--sops', '--no-sops', action=BooleanAction, default=True) | ||
539 | subparser.add_argument('--subject', metavar='CN', type=str, required=True) | ||
540 | subparser.add_argument('--alternative-name', metavar='CN', type=str, action='append') | ||
541 | subparser.add_argument('--output', type=Path, required=True) | ||
542 | subparser.set_defaults(cmd=new_client) | ||
543 | |||
544 | subparser = subparsers.add_parser('pkcs12', aliases=['p12', 'pfx'], formatter_class=argparse.ArgumentDefaultsHelpFormatter) | ||
545 | subparser.add_argument('--random-password', '--no-random-password', action=BooleanAction, default=True) | ||
546 | subparser.add_argument('--output', type=Path) | ||
547 | subparser.add_argument('filename', metavar='BASENAME', type=Path) | ||
548 | subparser.set_defaults(cmd=to_pkcs12) | ||
549 | |||
550 | args = parser.parse_args() | ||
551 | |||
552 | |||
553 | LOG_LEVELS = [logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR, logging.CRITICAL] | ||
554 | DEFAULT_LOG_LEVEL = logging.INFO | ||
555 | log_level = LOG_LEVELS.index(DEFAULT_LOG_LEVEL) | ||
556 | |||
557 | for adjustment in args.log_level or (): | ||
558 | log_level = min(len(LOG_LEVELS) - 1, max(log_level - adjustment, 0)) | ||
559 | logger.setLevel(LOG_LEVELS[log_level]) | ||
560 | |||
561 | |||
562 | logger.debug('Using cryptography %s (%s)', cryptography_version, openssl.backend.openssl_version_text()) | ||
563 | |||
564 | |||
565 | args.cmd(**{ k: v for k, v in vars(args).items() if k in signature(args.cmd).parameters.keys() }) | ||
566 | |||
567 | if __name__ == '__main__': | ||
568 | sys.exit(main()) | ||
diff --git a/tools/ca/setup.py b/tools/ca/setup.py new file mode 100644 index 00000000..3342a7a6 --- /dev/null +++ b/tools/ca/setup.py | |||
@@ -0,0 +1,10 @@ | |||
1 | from setuptools import setup | ||
2 | |||
3 | setup(name='ca', | ||
4 | packages=['ca'], | ||
5 | entry_points={ | ||
6 | 'console_scripts': [ | ||
7 | 'ca=ca.__main__:main' | ||
8 | ], | ||
9 | }, | ||
10 | ) | ||