diff options
Diffstat (limited to 'tools')
-rw-r--r-- | tools/.keep | 0 | ||||
-rw-r--r-- | tools/ca/ca/__main__.py | 667 | ||||
-rw-r--r-- | tools/ca/default.nix | 25 | ||||
-rw-r--r-- | tools/ca/setup.py | 10 | ||||
-rw-r--r-- | tools/sops-inventory/default.nix | 19 | ||||
-rw-r--r-- | tools/sops-inventory/setup.py | 11 | ||||
-rw-r--r-- | tools/sops-inventory/sops_inventory/__init__.py | 0 | ||||
-rw-r--r-- | tools/sops-inventory/sops_inventory/__main__.py | 85 | ||||
-rw-r--r-- | tools/tai64dec/default.nix | 18 | ||||
-rw-r--r-- | tools/tai64dec/setup.py | 10 | ||||
-rw-r--r-- | tools/tai64dec/tai64dec/__main__.py | 46 |
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 @@ | |||
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.x509.extensions import ExtensionNotFound | ||
16 | from cryptography.hazmat.primitives import serialization, hashes | ||
17 | from cryptography.hazmat.primitives.serialization import PrivateFormat, pkcs12 | ||
18 | from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey | ||
19 | from cryptography.hazmat.primitives.asymmetric.ed448 import Ed448PrivateKey | ||
20 | from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey | ||
21 | from cryptography.hazmat.primitives.asymmetric import rsa | ||
22 | from pathlib import Path | ||
23 | from atomicwrites import atomic_write | ||
24 | from fqdn import FQDN | ||
25 | from datetime import datetime, timedelta, timezone | ||
26 | from math import ceil, ldexp | ||
27 | import re | ||
28 | from getpass import getpass | ||
29 | from itertools import count | ||
30 | from tempfile import TemporaryFile, mkstemp | ||
31 | import subprocess | ||
32 | import json | ||
33 | from leapseconddata import LeapSecondData | ||
34 | from collections.abc import Iterable | ||
35 | import ipaddress | ||
36 | |||
37 | |||
38 | class 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 | |||
82 | class 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 | |||
104 | class 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 | |||
111 | def 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 | ||
145 | def 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 | |||
154 | class 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 | |||
161 | class 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 | |||
175 | def 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 | |||
203 | def 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 | |||
219 | def 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 | |||
237 | def 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 | |||
270 | def 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 | |||
308 | def 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 | |||
376 | def 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 | |||
461 | def 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 | |||
515 | def 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 | |||
579 | def 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 | |||
666 | if __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, ... }: | ||
2 | let | ||
3 | pkgs = self.legacyPackages.${system}; | ||
4 | in 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 @@ | |||
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 | ) | ||
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, ... }: | ||
2 | let | ||
3 | pkgs = self.legacyPackages.${system}; | ||
4 | in 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 @@ | |||
1 | from setuptools import setup | ||
2 | |||
3 | setup( | ||
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 @@ | |||
1 | import os,sys | ||
2 | |||
3 | from pathlib import Path | ||
4 | from collections import deque, defaultdict | ||
5 | |||
6 | import argparse | ||
7 | |||
8 | from yaml import load, YAMLError | ||
9 | try: | ||
10 | from yaml import CLoader as Loader | ||
11 | except ImportError: | ||
12 | from yaml import Loader | ||
13 | |||
14 | |||
15 | SOPS_TYPES = frozenset({'kms', 'gcp_kms', 'azure_kv', 'hc_vault', 'age', 'pgp'}) | ||
16 | |||
17 | |||
18 | class 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 | |||
26 | def 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 | |||
84 | if __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, ... }: | ||
2 | let | ||
3 | pkgs = self.legacyPackages.${system}; | ||
4 | in 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 @@ | |||
1 | from setuptools import setup | ||
2 | |||
3 | setup(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 @@ | |||
1 | import sys, os | ||
2 | |||
3 | import argparse | ||
4 | |||
5 | from leapseconddata import LeapSecondData | ||
6 | from math import ldexp | ||
7 | from pathlib import Path | ||
8 | from datetime import datetime, timezone | ||
9 | import secrets | ||
10 | |||
11 | |||
12 | class 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 | |||
20 | def 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 | |||
45 | if __name__ == '__main__': | ||
46 | sys.exit(main()) | ||