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()) | ||
