diff options
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 | ) | ||
