summaryrefslogtreecommitdiff
path: root/tools
diff options
context:
space:
mode:
authorGregor Kleen <gkleen@yggdrasil.li>2023-01-30 12:20:23 +0100
committerGregor Kleen <gkleen@yggdrasil.li>2023-01-30 12:20:23 +0100
commitcfc871cce6aefaa0ff64619780a807cba761c6b2 (patch)
tree965e8276ed36f11698b6c7d6eadab9f88d5f97c5 /tools
parentaa54fe89b98d354d21141c589332ce7950ef2e59 (diff)
downloadnixos-cfc871cce6aefaa0ff64619780a807cba761c6b2.tar
nixos-cfc871cce6aefaa0ff64619780a807cba761c6b2.tar.gz
nixos-cfc871cce6aefaa0ff64619780a807cba761c6b2.tar.bz2
nixos-cfc871cce6aefaa0ff64619780a807cba761c6b2.tar.xz
nixos-cfc871cce6aefaa0ff64619780a807cba761c6b2.zip
...
Diffstat (limited to 'tools')
-rw-r--r--tools/.keep0
-rw-r--r--tools/ca/ca/__main__.py667
-rw-r--r--tools/ca/default.nix25
-rw-r--r--tools/ca/setup.py10
-rw-r--r--tools/sops-inventory/default.nix19
-rw-r--r--tools/sops-inventory/setup.py11
-rw-r--r--tools/sops-inventory/sops_inventory/__init__.py0
-rw-r--r--tools/sops-inventory/sops_inventory/__main__.py85
-rw-r--r--tools/tai64dec/default.nix18
-rw-r--r--tools/tai64dec/setup.py10
-rw-r--r--tools/tai64dec/tai64dec/__main__.py46
11 files changed, 115 insertions, 776 deletions
diff --git a/tools/.keep b/tools/.keep
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/tools/.keep
diff --git a/tools/ca/ca/__main__.py b/tools/ca/ca/__main__.py
deleted file mode 100644
index bfaee63a..00000000
--- a/tools/ca/ca/__main__.py
+++ /dev/null
@@ -1,667 +0,0 @@
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.x509.extensions import ExtensionNotFound
16from cryptography.hazmat.primitives import serialization, hashes
17from cryptography.hazmat.primitives.serialization import PrivateFormat, pkcs12
18from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
19from cryptography.hazmat.primitives.asymmetric.ed448 import Ed448PrivateKey
20from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
21from cryptography.hazmat.primitives.asymmetric import rsa
22from pathlib import Path
23from atomicwrites import atomic_write
24from fqdn import FQDN
25from datetime import datetime, timedelta, timezone
26from math import ceil, ldexp
27import re
28from getpass import getpass
29from itertools import count
30from tempfile import TemporaryFile, mkstemp
31import subprocess
32import json
33from leapseconddata import LeapSecondData
34from collections.abc import Iterable
35import ipaddress
36
37
38class KeyType(Enum):
39 ED448 = 'ed448'
40 ED25519 = 'ed25519'
41 RSA4096 = 'rsa4096'
42 RSA2048 = 'rsa2048'
43
44 def generate(self):
45 match self:
46 case KeyType.ED448:
47 return Ed448PrivateKey.generate()
48 case KeyType.ED25519:
49 return Ed25519PrivateKey.generate()
50 case KeyType.RSA4096:
51 return rsa.generate_private_key(
52 public_exponent = 65537,
53 key_size = 4096,
54 )
55 case KeyType.RSA2048:
56 return rsa.generate_private_key(
57 public_exponent = 65537,
58 key_size = 2048,
59 )
60
61 def aligned(self, key):
62 match self:
63 case KeyType.ED448:
64 return isinstance(key, Ed448PrivateKey)
65 case KeyType.ED25519:
66 return isinstance(key, Ed25519PrivateKey)
67 case KeyType.RSA4096:
68 return isinstance(key, RSAPrivateKey) and key.key_size == 4096
69 case KeyType.RSA2048:
70 return isinstance(key, RSAPrivateKey) and key.key_size == 2048
71
72 def __str__(self):
73 return self.value
74
75 @classmethod
76 def from_string(cls, s):
77 try:
78 return cls(s)
79 except KeyError:
80 raise ValueError()
81
82class SupportedKeyUsage(Enum):
83 SERVER_AUTH = 'server'
84 CLIENT_AUTH = 'client'
85
86 @property
87 def oid(self):
88 match self:
89 case SupportedKeyUsage.SERVER_AUTH:
90 return ExtendedKeyUsageOID.SERVER_AUTH
91 case SupportedKeyUsage.CLIENT_AUTH:
92 return ExtendedKeyUsageOID.CLIENT_AUTH
93
94 def __str__(self):
95 return self.value
96
97 @classmethod
98 def from_string(cls, s):
99 try:
100 return cls(s)
101 except KeyError:
102 raise ValueError()
103
104class ValidFQDN(FQDN):
105 def __init__(self, *args, **kwds):
106 super().__init__(*args, **kwds)
107
108 if not self.is_valid:
109 raise ValueError(f'‘{self}’ is not valid')
110
111def duration(inp_str):
112 delta = timedelta()
113
114 item_re = re.compile(r'\W*(?P<value>\d+)\W*(?P<unit>(?i:d|h|m(?!s)|s|ms|µs))')
115
116 match = item_re.match(inp_str)
117 while match:
118 val = int(match.group('value'))
119 unit = match.group('unit').lower()
120
121 if unit == 'd':
122 delta += timedelta(days=val)
123 elif unit == 'h':
124 delta += timedelta(hours=val)
125 elif unit == 'm':
126 delta += timedelta(minutes=val)
127 elif unit == 's':
128 delta += timedelta(seconds=val)
129 elif unit == 'ms':
130 delta += timedelta(milliseconds=val)
131 elif unit == 'µs' or unit == 'us':
132 delta += timedelta(microseconds=val)
133 else:
134 raise ValueError(f'Unknown time unit ‘{unit:s}’')
135
136 inp_str = inp_str[match.end():]
137 match = item_re.match(inp_str)
138 else:
139 if re.match('\w', inp_str):
140 raise ValueError(f'Parsing of duration resulted in leftovers: ‘{inp_str:s}’')
141
142 return delta
143
144@contextmanager
145def umask(desired_umask):
146 """ A little helper to safely set and restore umask(2). """
147 try:
148 prev_umask = os.umask(0)
149 os.umask(prev_umask | desired_umask)
150 yield
151 finally:
152 os.umask(prev_umask)
153
154class BooleanAction(argparse.Action):
155 def __init__(self, option_strings, dest, nargs=None, **kwargs):
156 super(BooleanAction, self).__init__(option_strings, dest, nargs=0, **kwargs)
157
158 def __call__(self, parser, namespace, values, option_string=None):
159 setattr(namespace, self.dest, False if option_string.startswith('--no') else True)
160
161class ExtendAction(argparse.Action):
162 def __init__(self, *args, **kwargs):
163 super().__init__(*args, **kwargs)
164 self.reset_dest = False
165 def __call__(self, parser, namespace, values, option_string=None):
166 if not self.reset_dest:
167 setattr(namespace, self.dest, [])
168 self.reset_dest = True
169 if isinstance(values, Iterable):
170 getattr(namespace, self.dest).extend(values)
171 else:
172 getattr(namespace, self.dest).append(values)
173
174
175def load_key(keyfile, prompt='CA private key password: '):
176 key = None
177 with open(keyfile, 'rb') as f:
178 is_sops = False
179 try:
180 sops_json = json.load(f)
181 is_sops = 'sops' in sops_json
182 except json.JSONDecodeError:
183 pass
184
185 f.seek(0)
186
187 if not is_sops:
188 try:
189 key = serialization.load_pem_private_key(f.read(), password=None)
190 except TypeError:
191 pw = getpass(prompt=prompt)
192 key = serialization.load_pem_private_key(f.read(), password=bytes(pw, sys.stdin.encoding))
193 else:
194 cmd = ['sops', '-d', f'/dev/fd/{f.fileno()}']
195 with subprocess.Popen(cmd, stdout=subprocess.PIPE, pass_fds=(f.fileno(),)) as proc:
196 key = serialization.load_pem_private_key(proc.stdout.read(), password=None)
197 ret = proc.wait()
198 if ret != 0:
199 raise subprocess.CalledProcessErrror(ret, cmd)
200
201 return key
202
203def mv_bak(path):
204 global logger
205
206 bak_path = path.parent / f'{path.name}.bak'
207 for n in count(2):
208 if not bak_path.exists():
209 break
210 bak_path = path.parent / f'{path.name}.bak{n}'
211
212 try:
213 path.rename(bak_path)
214 except FileNotFoundError:
215 pass
216 else:
217 logger.warn('Renamed ‘%s’ to ‘%s’...', path, bak_path)
218
219def tai64nint(dt):
220 global leapsecond_data
221
222 have_data = False
223 try:
224 have_data = bool(leapsecond_data)
225 except NameError:
226 pass
227
228 if not have_data:
229 leapsecond_data = LeapSecondData.from_file(Path(os.getenv('LEAPSECONDS_FILE')))
230
231 tai_dt = leapsecond_data.to_tai(dt)
232 seconds = int(tai_dt.timestamp())
233 nanoseconds = int((tai_dt.timestamp() - seconds) / 1e-9)
234 seconds += int(ldexp(1, 62))
235 return seconds << 32 | nanoseconds
236
237def write_genkey(key_type, sops, keyfile):
238 if keyfile.exists():
239 raise ValueError(f'Keyfile exists: {keyfile}')
240
241 key = None
242
243 def genkey(fh):
244 nonlocal key, key_type
245
246 logger.debug('Generating new privkey...')
247 key = key_type.generate()
248 priv_bytes = key.private_bytes(encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption())
249 fh.write(priv_bytes)
250
251 if not sops:
252 with umask(0o0177), atomic_write(keyfile, overwrite=False, mode='wb') as fh:
253 logger.info('Writing new privkey to ‘%s’...', keyfile)
254 genkey(fh)
255 logger.debug('Adjusting permissions for ‘%s’...', keyfile)
256 os.chmod(keyfile, 0o0400)
257 else:
258 with TemporaryFile(mode='wb') as tf:
259 genkey(tf)
260 tf.seek(0)
261
262 with umask(0o0177), atomic_write(keyfile, overwrite=False, mode='wb') as fh:
263 logger.info('Encrypting new privkey to ‘%s’...', keyfile)
264 subprocess.run(['sops', '-e', f'/dev/fd/{tf.fileno()}'], stdout=fh, pass_fds=(tf.fileno(),), check=True)
265 logger.debug('Adjusting permissions for ‘%s’...', keyfile)
266 os.chmod(keyfile, 0o0400)
267
268 return key
269
270def to_dn(alternative_names):
271 def go(alternative_name):
272 dn = None
273 try:
274 dn = x509.Name.from_rfc4514_string(alternative_name)
275 except ValueError:
276 pass
277
278 if dn:
279 logger.info('‘%s’ interpreted as directory name: %s', alternative_name, dn)
280 return x509.DirectoryName(dn)
281
282 addr = None
283 try:
284 addr = ipaddress.IPv4Network(alternative_name)
285 except (ipaddress.AddressValueError, ipaddress.NetmaskValueError, ValueError):
286 pass
287 try:
288 addr = ipaddress.IPv4Address(alternative_name)
289 except ipaddress.AddressValueError:
290 pass
291 try:
292 addr = ipaddress.IPv6Network(alternative_name)
293 except (ipaddress.AddressValueError, ipaddress.NetmaskValueError, ValueError):
294 pass
295 try:
296 addr = ipaddress.IPv6Address(alternative_name)
297 except ipaddress.AddressValueError:
298 pass
299
300 if addr:
301 logger.info('‘%s’ interpreted as ip address/subnet: %s', alternative_name, addr)
302 return x509.IPAddress(addr)
303
304 return x509.DNSName(alternative_name)
305
306 return map(go, alternative_names)
307
308def initca(ca_cert, ca_key, key_type, subject, clock_skew, validity, sops):
309 global logger
310
311 key = None
312 try:
313 key = load_key(ca_key)
314 logger.info('Successfully loaded privkey from ‘%s’', ca_key)
315
316 if not key_type.aligned(key):
317 logger.warn('Private key ‘%s’ does not align with requested type %s', ca_key, key_type)
318
319 mv_bak(ca_key)
320 mv_bak(ca_cert)
321
322 raise FileNotFoundError(f'Key does not align with requested type: {ca_key}')
323 except FileNotFoundError:
324 key = write_genkey(key_type, sops, ca_key)
325
326 cert = None
327 try:
328 with open(ca_cert, 'rb') as fh:
329 cert = x509.load_pem_x509_certificate(fh.read())
330 logger.info('Successfully loaded certificate from ‘%s’', ca_cert)
331 except FileNotFoundError:
332 logger.debug('Generating new certificate...')
333
334 now = datetime.utcnow()
335 name = None
336 try:
337 name = x509.Name.from_rfc4514_string(subject)
338 logger.info('‘%s’ interpreted as directory name: %s', subject, name)
339 except ValueError:
340 name = x509.Name([
341 x509.NameAttribute(NameOID.COMMON_NAME, subject)
342 ])
343
344 cert = x509.CertificateBuilder().subject_name(
345 name
346 ).public_key(
347 key.public_key()
348 ).serial_number(
349 x509.random_serial_number()
350 ).not_valid_before(
351 now - clock_skew
352 ).not_valid_after(
353 now + validity
354 ).issuer_name(
355 name
356 ).add_extension(
357 x509.AuthorityKeyIdentifier.from_issuer_public_key(key.public_key()),
358 False
359 ).add_extension(
360 x509.SubjectKeyIdentifier.from_public_key(key.public_key()),
361 False
362 ).add_extension(
363 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),
364 True
365 ).add_extension(
366 x509.BasicConstraints(ca=True, path_length=None),
367 True
368 ).sign(key, None if isinstance(key, Ed25519PrivateKey) or isinstance(key, Ed448PrivateKey) else hashes.SHA512())
369
370 with umask(0o0133), atomic_write(ca_cert, overwrite=False, mode='wb') as cf:
371 logger.info('Writing new certificate to ‘%s’...', ca_cert)
372 cf.write(cert.public_bytes(serialization.Encoding.PEM))
373 logger.debug('Adjusting permissions for ‘%s’...', ca_cert)
374 os.chmod(ca_cert, 0o0444)
375
376def signcsr(ca_cert, ca_key, clock_skew, validity, subject, alternative_name, key_usage, ignore_alternative_names, csr, output):
377 if not key_usage:
378 raise InvalidParamsError('No extended key usages specified')
379
380 csr_bytes = None
381 try:
382 csr_bytes = csr.read()
383 except AttributeError:
384 csr_bytes = csr
385
386 csr = x509.load_pem_x509_csr(csr_bytes)
387 name = None
388 if not subject:
389 name = csr.subject
390 else:
391 try:
392 name = x509.Name.from_rfc4514_string(subject)
393 logger.info('‘%s’ interpreted as directory name: %s', subject, name)
394 except ValueError:
395 name = x509.Name([
396 x509.NameAttribute(NameOID.COMMON_NAME, subject)
397 ])
398
399 if not ignore_alternative_names:
400 try:
401 ext = csr.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME)
402 csr_alt_names = set(ext.value)
403 logger.warn('Using alternative names from csr: %s', csr_alt_names)
404 alternative_name = set(to_dn(alternative_name)) | csr_alt_names
405 except ExtensionNotFound:
406 pass
407 else:
408 alternative_name = to_dn(alternative_name)
409
410 ca_key = load_key(ca_key)
411 with open(ca_cert, 'rb') as fh:
412 ca_cert = x509.load_pem_x509_certificate(fh.read())
413
414 now = datetime.now(tz=timezone.utc)
415 cert = x509.CertificateBuilder().subject_name(
416 name
417 ).public_key(
418 csr.public_key()
419 ).serial_number(
420 (tai64nint(now) << 24) | (x509.random_serial_number() & int(ldexp(1, 24) - 1))
421 ).not_valid_before(
422 now - clock_skew
423 ).not_valid_after(
424 now + validity
425 ).issuer_name(
426 ca_cert.subject
427 ).add_extension(
428 x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_cert.public_key()),
429 False
430 ).add_extension(
431 x509.SubjectKeyIdentifier.from_public_key(csr.public_key()),
432 False
433 ).add_extension(
434 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),
435 True
436 ).add_extension(
437 x509.BasicConstraints(ca=False, path_length=None),
438 True
439 ).add_extension(
440 x509.ExtendedKeyUsage(list(map(lambda ku: ku.oid, key_usage))),
441 False
442 )
443
444 if alternative_name:
445 cert = cert.add_extension(
446 x509.SubjectAlternativeName(alternative_name),
447 False
448 )
449
450 cert = cert.sign(ca_key, None if isinstance(ca_key, Ed25519PrivateKey) or isinstance(ca_key, Ed448PrivateKey) else hashes.SHA256())
451
452 output = output.with_suffix('.crt')
453
454 mv_bak(output)
455 with umask(0o0133), atomic_write(output, overwrite=False, mode='wb') as cf:
456 logger.info('Writing new certificate to ‘%s’...', output)
457 cf.write(cert.public_bytes(serialization.Encoding.PEM))
458 logger.debug('Adjusting permissions for ‘%s’...', output)
459 os.chmod(output, 0o0444)
460
461def new_client(ca_cert, ca_key, key_type, clock_skew, validity, subject, alternative_name, key_usage, sops, output):
462 key_file = output.with_suffix('.key')
463 cert_file = output.with_suffix('.crt')
464
465 key = None
466 try:
467 key = load_key(key_file)
468 logger.info('Successfully loaded privkey from ‘%s’', key_file)
469
470 if not key_type.aligned(key):
471 logger.warn('Private key ‘%s’ does not align with requested type %s', key_file, key_type)
472
473 mv_bak(key_file)
474 mv_bak(cert_file)
475
476 raise FileNotFoundError(f'Key does not align with requested type: {key_file}')
477 except FileNotFoundError:
478 key = write_genkey(key_type, sops, key_file)
479
480 name = None
481 try:
482 name = x509.Name.from_rfc4514_string(subject)
483 logger.info('‘%s’ interpreted as directory name: %s', subject, name)
484 except ValueError:
485 name = x509.Name([
486 x509.NameAttribute(NameOID.COMMON_NAME, subject)
487 ])
488
489 csr = x509.CertificateSigningRequestBuilder().subject_name(name)
490
491 if alternative_name:
492 csr = csr.add_extension(
493 x509.SubjectAlternativeName(
494 to_dn(alternative_name)
495 ),
496 False
497 )
498
499 return signcsr(
500 ca_cert=ca_cert,
501 ca_key=ca_key,
502 clock_skew=clock_skew,
503 validity=validity,
504 subject=None,
505 alternative_name=[],
506 key_usage=key_usage,
507 ignore_alternative_names=False,
508 output=cert_file,
509 csr=csr.sign(
510 key,
511 None if isinstance(key, Ed25519PrivateKey) or isinstance(key, Ed448PrivateKey) else hashes.SHA256(),
512 ).public_bytes(serialization.Encoding.PEM)
513 )
514
515def to_pkcs12(random_password, random_password_length, weak_encryption, filename, temporary_output, output):
516 key_file = filename.with_suffix('.key')
517 cert_file = filename.with_suffix('.crt')
518
519 output_handle = None
520 if not output:
521 if not temporary_output:
522 output = filename.with_suffix('.p12')
523 else:
524 output_handle, output = mkstemp(suffix='.p12', prefix=filename.stem + '.')
525
526 key = load_key(key_file)
527 logger.info('Successfully loaded privkey from ‘%s’', key_file)
528 cert = None
529 with open(cert_file, mode='rb') as fh:
530 cert = x509.load_pem_x509_certificate(fh.read())
531 logger.info('Successfully loaded certificate from ‘%s’', cert_file)
532
533 with umask(0o0177), atomic_write(output, overwrite=False, mode='wb') if not output_handle else os.fdopen(output_handle, mode='wb') as fh:
534 logger.info('Writing to ‘%s’...', output)
535 common_name_attrs = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)
536 if len(common_name_attrs) != 1:
537 raise InvalidParamsError('Invalid name structure in cert')
538 subject = common_name_attrs[0].value.lower()
539
540 pw = None
541 if not random_password:
542 pw2 = None
543 while not pw2 or pw2 != pw:
544 pw = getpass(prompt='Password: ')
545 if not pw:
546 pw = None
547 break
548 else:
549 pw2 = getpass(prompt='Repeat password: ')
550 else:
551 from xkcdpass import xkcd_password as xp
552 ws = xp.generate_wordlist(wordfile=xp.locate_wordfile())
553 pw = xp.generate_xkcdpassword(ws, numwords=random_password_length)
554 print(f'Password: {pw}', file=sys.stderr)
555
556 encryption = None
557 if pw:
558 encryption = PrivateFormat.PKCS12.encryption_builder().kdf_rounds(
559 500000 if not weak_encryption else 50000
560 ).key_cert_algorithm(
561 pkcs12.PBES.PBESv2SHA256AndAES256CBC if not weak_encryption else pkcs12.PBES.PBESv1SHA1And3KeyTripleDESCBC
562 ).hmac_hash(
563 hashes.SHA256() if not weak_encryption else hashes.SHA1()
564 ).build(bytes(pw, 'utf-8'))
565 fh.write(pkcs12.serialize_key_and_certificates(
566 bytes(subject, 'utf-8'),
567 key,
568 cert,
569 None,
570 encryption,
571 ))
572 logger.debug('Adjusting permissions for ‘%s’...', output)
573 os.chmod(output, 0o0400)
574
575 if temporary_output:
576 print(f'Temporary output file: {output}', file=sys.stderr)
577
578
579def main():
580 global logger
581 logger = logging.getLogger(__name__)
582 console_handler = logging.StreamHandler()
583 console_handler.setFormatter( logging.Formatter('[%(levelname)s](%(name)s): %(message)s') )
584 if sys.stderr.isatty():
585 console_handler.setFormatter( logging.Formatter('%(asctime)s [%(levelname)s](%(name)s): %(message)s') )
586 logger.addHandler(console_handler)
587
588 # log uncaught exceptions
589 def log_exceptions(type, value, tb):
590 global logger
591
592 logger.error(value)
593 sys.__excepthook__(type, value, tb) # calls default excepthook
594
595 sys.excepthook = log_exceptions
596
597
598 parser = argparse.ArgumentParser(prog='ca', formatter_class=argparse.ArgumentDefaultsHelpFormatter)
599 parser.add_argument('--verbosity', dest='log_level', action='append', type=int, help='Numeric verbosity')
600 parser.add_argument('--verbose', '-v', dest='log_level', action='append_const', const=1, help='Increase verbosity')
601 parser.add_argument('--quiet', '-q', dest='log_level', action='append_const', const=-1, help='Decrease verbosity')
602 subparsers = parser.add_subparsers(help='Subcommands', required=True)
603
604 subparser = subparsers.add_parser('init', aliases=['initca', 'init-ca', 'ca'], formatter_class=argparse.ArgumentDefaultsHelpFormatter, description="Generate a new selfsigned CA certificate and associated private key\n\nPrivate key is only generated if it does not yet exist")
605 subparser.add_argument('--ca-cert', type=Path, default=Path('ca.crt'), help='Path to file containing CA certificate')
606 subparser.add_argument('--ca-key', type=Path, default=Path('ca.key'), help='Path to file containing CA private key')
607 subparser.add_argument('--key-type', type=KeyType.from_string, choices=list(KeyType), default=KeyType.ED448.value, help='Type of private key to generate')
608 subparser.add_argument('--clock-skew', metavar='DURATION', type=duration, default=timedelta(minutes=5), help='How far to shift begin of validity into the past')
609 subparser.add_argument('--validity', metavar='DURATION', type=duration, default=timedelta(days=ceil(365.2425*10)), help='How far to shift end of validity into the future')
610 subparser.add_argument('--sops', '--no-sops', action=BooleanAction, default=True, help='Encrypt private key using SOPS')
611 subparser.add_argument('--subject', metavar='DN', type=str, required=True, help='Subject name')
612 subparser.set_defaults(cmd=initca)
613
614 subparser = subparsers.add_parser('sign', aliases=['signcsr', 'sign-csr'], formatter_class=argparse.ArgumentDefaultsHelpFormatter, description='Sign an existing CSR')
615 subparser.add_argument('--ca-cert', type=Path, default=Path('ca.crt'), help='Path to file containing CA certificate')
616 subparser.add_argument('--ca-key', type=Path, default=Path('ca.key'), help='Path to file containing CA private key')
617 subparser.add_argument('--clock-skew', metavar='DURATION', type=duration, default=timedelta(minutes=5), help='How far to shift begin of validity into the past')
618 subparser.add_argument('--validity', metavar='DURATION', type=duration, default=timedelta(days=ceil(365.2425*10)), help='How far to shift end of validity into the future')
619 subparser.add_argument('--subject', metavar='DN', type=str, required=False, help='Override subject name')
620 subparser.add_argument('--ignore-alternative-names', '--no-ignore-alternative-names', action=BooleanAction, default=True, help='Ignore subject alternative names provided in CSR')
621 subparser.add_argument('--key-usage', metavar='KEY_USAGE', type=SupportedKeyUsage, action=ExtendAction, default=[SupportedKeyUsage.CLIENT_AUTH], help='Allowed key usages')
622 subparser.add_argument('--alternative-name', metavar='CN', type=str, action='append', help='Subject alternative names')
623 subparser.add_argument('--output', type=Path, required=True, help='Output path')
624 subparser.add_argument('csr', metavar='FILE', type=argparse.FileType(mode='rb'), help='Path to file containing CSR')
625 subparser.set_defaults(cmd=signcsr)
626
627 subparser = subparsers.add_parser('new-client', aliases=['new', 'new-client', 'client'], formatter_class=argparse.ArgumentDefaultsHelpFormatter, description='Generate a new CSR and sign it immediately')
628 subparser.add_argument('--ca-cert', type=Path, default=Path('ca.crt'), help='Path to file containing CA certificate')
629 subparser.add_argument('--ca-key', type=Path, default=Path('ca.key'), help='Path to file containing CA private key')
630 subparser.add_argument('--key-type', type=KeyType.from_string, choices=list(KeyType), default=KeyType.ED25519.value, help='Type of private key to generate')
631 subparser.add_argument('--clock-skew', metavar='DURATION', type=duration, default=timedelta(minutes=5), help='How far to shift begin of validity into the past')
632 subparser.add_argument('--validity', metavar='DURATION', type=duration, default=timedelta(days=ceil(365.2425*10)), help='How far to shift end of validity into the future')
633 subparser.add_argument('--sops', '--no-sops', action=BooleanAction, default=True, help='Encrypt private key using SOPS')
634 subparser.add_argument('--subject', metavar='DN', type=str, required=True, help='Subject name')
635 subparser.add_argument('--key-usage', metavar='KEY_USAGE', type=SupportedKeyUsage, action=ExtendAction, default=[SupportedKeyUsage.CLIENT_AUTH], help='Allowed key usages')
636 subparser.add_argument('--alternative-name', metavar='CN', type=str, action='append', help='Subject alternative names')
637 subparser.add_argument('--output', type=Path, required=True, help='Output path')
638 subparser.set_defaults(cmd=new_client)
639
640 subparser = subparsers.add_parser('pkcs12', aliases=['p12', 'pfx'], formatter_class=argparse.ArgumentDefaultsHelpFormatter, description='Convert existing certificate and private key to PKCS#12 format')
641 subparser.add_argument('--random-password', '--no-random-password', action=BooleanAction, default=True, help='Encrypt PKCS#12 file with random passphrase -- otherwise prompt for one')
642 subparser.add_argument('--random-password-length', type=int, default=12, help='Number of words in random passphrase')
643 subparser.add_argument('--weak-encryption', '--no-weak-encryption', action=BooleanAction, default=False, help='Use weak, but more compatible, encryption')
644 subparser.add_argument('--temporary-output', '--no-temporary-output', action=BooleanAction, default=True, help='If output path is not given, generate output file in temporary directory')
645 subparser.add_argument('--output', type=Path, help='Output path')
646 subparser.add_argument('filename', metavar='BASENAME', type=Path, help='Input path')
647 subparser.set_defaults(cmd=to_pkcs12)
648
649 args = parser.parse_args()
650
651
652 LOG_LEVELS = [logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR, logging.CRITICAL]
653 DEFAULT_LOG_LEVEL = logging.INFO
654 log_level = LOG_LEVELS.index(DEFAULT_LOG_LEVEL)
655
656 for adjustment in args.log_level or ():
657 log_level = min(len(LOG_LEVELS) - 1, max(log_level - adjustment, 0))
658 logger.setLevel(LOG_LEVELS[log_level])
659
660
661 logger.debug('Using cryptography %s (%s)', cryptography_version, openssl.backend.openssl_version_text())
662
663
664 args.cmd(**{ k: v for k, v in vars(args).items() if k in signature(args.cmd).parameters.keys() })
665
666if __name__ == '__main__':
667 sys.exit(main())
diff --git a/tools/ca/default.nix b/tools/ca/default.nix
deleted file mode 100644
index c5fe0cea..00000000
--- a/tools/ca/default.nix
+++ /dev/null
@@ -1,25 +0,0 @@
1{ system, self, mach-nix, leapseconds, ... }:
2let
3 pkgs = self.legacyPackages.${system};
4in mach-nix.lib.${system}.buildPythonPackage {
5 pname = "ca";
6 src = pkgs.lib.sourceByRegex ./. ["^setup\.py$" "^ca(/[^/]+.*)?$"];
7 version = "0.0.0";
8 ignoreDataOutdated = true;
9
10 requirements = ''
11 cryptography >=38.0.0
12 fqdn
13 atomicwrites
14 leapseconddata
15 xkcdpass
16 '';
17
18 _.cryptography.buildInputs = with pkgs; [ openssl ];
19
20 postInstall = ''
21 wrapProgram $out/bin/ca \
22 --set-default LEAPSECONDS_FILE ${leapseconds} \
23 --prefix PATH : ${pkgs.lib.makeBinPath (with pkgs; [sops])}
24 '';
25}
diff --git a/tools/ca/setup.py b/tools/ca/setup.py
deleted file mode 100644
index 3342a7a6..00000000
--- a/tools/ca/setup.py
+++ /dev/null
@@ -1,10 +0,0 @@
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)
diff --git a/tools/sops-inventory/default.nix b/tools/sops-inventory/default.nix
new file mode 100644
index 00000000..94c455e5
--- /dev/null
+++ b/tools/sops-inventory/default.nix
@@ -0,0 +1,19 @@
1{ system, self, mach-nix, ... }:
2let
3 pkgs = self.legacyPackages.${system};
4in mach-nix.lib.${system}.buildPythonPackage {
5 pname = "sops-inventory";
6 version = "0.0.0";
7
8 src = pkgs.lib.sourceByRegex ./. ["^setup\.py$" "^sops_inventory(/[^/]+.*)?$"];
9
10 ignoreDataOutdated = true;
11 requirements = ''
12 pyyaml
13 '';
14
15 postInstall = ''
16 wrapProgram $out/bin/sops-inventory \
17 --set-default SOPS_INVENTORY_BASE ${self}
18 '';
19}
diff --git a/tools/sops-inventory/setup.py b/tools/sops-inventory/setup.py
new file mode 100644
index 00000000..3ea2a5d1
--- /dev/null
+++ b/tools/sops-inventory/setup.py
@@ -0,0 +1,11 @@
1from setuptools import setup
2
3setup(
4 name='sops-inventory',
5 packages=['sops_inventory'],
6 entry_points={
7 'console_scripts': [
8 'sops-inventory=sops_inventory.__main__:main'
9 ],
10 },
11)
diff --git a/tools/sops-inventory/sops_inventory/__init__.py b/tools/sops-inventory/sops_inventory/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/tools/sops-inventory/sops_inventory/__init__.py
diff --git a/tools/sops-inventory/sops_inventory/__main__.py b/tools/sops-inventory/sops_inventory/__main__.py
new file mode 100644
index 00000000..68f72b60
--- /dev/null
+++ b/tools/sops-inventory/sops_inventory/__main__.py
@@ -0,0 +1,85 @@
1import os,sys
2
3from pathlib import Path
4from collections import deque, defaultdict
5
6import argparse
7
8from yaml import load, YAMLError
9try:
10 from yaml import CLoader as Loader
11except ImportError:
12 from yaml import Loader
13
14
15SOPS_TYPES = frozenset({'kms', 'gcp_kms', 'azure_kv', 'hc_vault', 'age', 'pgp'})
16
17
18class BooleanAction(argparse.Action):
19 def __init__(self, option_strings, dest, nargs=None, **kwargs):
20 super(BooleanAction, self).__init__(option_strings, dest, nargs=0, **kwargs)
21
22 def __call__(self, parser, namespace, values, option_string=None):
23 setattr(namespace, self.dest, False if option_string.startswith('--no') else True)
24
25
26def main():
27 default_base = os.getenv('SOPS_INVENTORY_BASE', default=[])
28 if default_base:
29 default_base = Path(default_base)
30
31 parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter)
32 parser.add_argument('--list-files', '--no-list-files', action=BooleanAction, default=False, help='Only list sops files')
33 parser.add_argument('path', metavar='PATH', nargs='?' if default_base else None, type=Path, default=default_base, help='Base directory to take inventory of')
34 args = parser.parse_args()
35
36 inventory = defaultdict(set)
37
38 queue = deque([args.path])
39 while queue:
40 baseDir = queue.popleft()
41 for child in baseDir.iterdir():
42 if child.is_dir():
43 queue.append(child)
44 else:
45 try:
46 with child.open(mode='r') as fh:
47 yaml = load(fh, Loader=Loader)
48 if not yaml:
49 raise ValueError('Could not parse YAML')
50 if not isinstance(yaml, dict) or not 'sops' in yaml:
51 raise ValueError('Did not find "sops" key')
52 sops = yaml['sops']
53
54 key_info = set()
55 for k in SOPS_TYPES:
56 if k in sops:
57 v = sops[k]
58 if not v:
59 continue
60
61 match k:
62 case 'pgp':
63 for r in v:
64 key_info.add(r['fp'])
65 case 'age':
66 for r in v:
67 key_info.add(r['recipient'])
68 case _:
69 raise NotImplementedError
70 inventory[frozenset(key_info)].add(child.relative_to(args.path))
71 except (YAMLError, ValueError) as e:
72 pass
73
74 if not args.list_files:
75 for keys, files in inventory.items():
76 print(','.join(keys) + ':')
77 for file in files:
78 print(' - ' + str(file))
79 else:
80 for _, files in inventory.items():
81 for file in files:
82 print(file)
83
84if __name__ == '__main__':
85 os.exit(main())
diff --git a/tools/tai64dec/default.nix b/tools/tai64dec/default.nix
deleted file mode 100644
index 380c22bf..00000000
--- a/tools/tai64dec/default.nix
+++ /dev/null
@@ -1,18 +0,0 @@
1{ system, self, mach-nix, leapseconds, ... }:
2let
3 pkgs = self.legacyPackages.${system};
4in mach-nix.lib.${system}.buildPythonPackage {
5 pname = "tai64dec";
6 src = pkgs.lib.sourceByRegex ./. ["^setup\.py$" "^tai64dec(/[^/]+.*)?$"];
7 version = "0.0.0";
8 ignoreDataOutdated = true;
9
10 requirements = ''
11 leapseconddata
12 '';
13
14 postInstall = ''
15 wrapProgram $out/bin/tai64dec \
16 --set-default LEAPSECONDS_FILE ${leapseconds}
17 '';
18}
diff --git a/tools/tai64dec/setup.py b/tools/tai64dec/setup.py
deleted file mode 100644
index d936796b..00000000
--- a/tools/tai64dec/setup.py
+++ /dev/null
@@ -1,10 +0,0 @@
1from setuptools import setup
2
3setup(name='tai64dec',
4 packages=['tai64dec'],
5 entry_points={
6 'console_scripts': [
7 'tai64dec=tai64dec.__main__:main'
8 ],
9 },
10)
diff --git a/tools/tai64dec/tai64dec/__main__.py b/tools/tai64dec/tai64dec/__main__.py
deleted file mode 100644
index a8854523..00000000
--- a/tools/tai64dec/tai64dec/__main__.py
+++ /dev/null
@@ -1,46 +0,0 @@
1import sys, os
2
3import argparse
4
5from leapseconddata import LeapSecondData
6from math import ldexp
7from pathlib import Path
8from datetime import datetime, timezone
9import secrets
10
11
12class BooleanAction(argparse.Action):
13 def __init__(self, option_strings, dest, nargs=None, **kwargs):
14 super(BooleanAction, self).__init__(option_strings, dest, nargs=0, **kwargs)
15
16 def __call__(self, parser, namespace, values, option_string=None):
17 setattr(namespace, self.dest, False if option_string.startswith('--no') else True)
18
19
20def main():
21 parser = argparse.ArgumentParser(prog='tai64dec', formatter_class=argparse.ArgumentDefaultsHelpFormatter)
22 parser.add_argument('--random', '--no-random', action=BooleanAction, default=False)
23 parser.add_argument('--ns', '--no-ns', action=BooleanAction, default=True)
24 args = parser.parse_args()
25
26
27 leapsecond_data = LeapSecondData.from_file(Path(os.getenv('LEAPSECONDS_FILE')))
28
29 now = datetime.now(tz=timezone.utc)
30
31 tai_dt = leapsecond_data.to_tai(now)
32 seconds = int(tai_dt.timestamp())
33 seconds += int(ldexp(1, 62))
34 out = seconds
35
36 if args.ns:
37 nanoseconds = int((tai_dt.timestamp() - seconds) / 1e-9)
38 out = out << 32 | nanoseconds
39
40 if args.random:
41 out = out << 24 | int.from_bytes(secrets.token_bytes(3), byteorder='little', signed=False)
42
43 print('{:d}'.format(out), file=sys.stdout)
44
45if __name__ == '__main__':
46 sys.exit(main())