summaryrefslogtreecommitdiff
path: root/tools
diff options
context:
space:
mode:
Diffstat (limited to 'tools')
-rw-r--r--tools/ca/ca/__main__.py568
-rw-r--r--tools/ca/setup.py10
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 @@
1import sys, os
2
3import logging
4import argparse
5
6from inspect import signature
7
8from enum import Enum, auto
9from contextlib import contextmanager
10
11from cryptography import __version__ as cryptography_version
12from cryptography.hazmat.backends import openssl
13from cryptography import x509
14from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID, ExtensionOID
15from cryptography.hazmat.primitives import serialization, hashes
16from cryptography.hazmat.primitives.serialization import PrivateFormat, pkcs12
17from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
18from cryptography.hazmat.primitives.asymmetric.ed448 import Ed448PrivateKey
19from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
20from cryptography.hazmat.primitives.asymmetric import rsa
21from pathlib import Path
22from atomicwrites import atomic_write
23from fqdn import FQDN
24from datetime import datetime, timedelta, timezone
25from math import ceil, ldexp
26import re
27from getpass import getpass
28from itertools import count
29from tempfile import TemporaryFile
30import subprocess
31import json
32from leapseconddata import LeapSecondData
33
34
35class 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
79class 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
86def 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
120def 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
129class 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
137def 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
165def 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
177def 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
195def 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
228def 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
297def 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
377def 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
428def 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
485def 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
567if __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 @@
1from setuptools import setup
2
3setup(name='ca',
4 packages=['ca'],
5 entry_points={
6 'console_scripts': [
7 'ca=ca.__main__:main'
8 ],
9 },
10)