diff options
-rw-r--r-- | flake.nix | 2 | ||||
-rw-r--r-- | shell.nix | 4 | ||||
-rw-r--r-- | tools/ca/ca/__main__.py | 207 |
3 files changed, 128 insertions, 85 deletions
@@ -217,6 +217,8 @@ | |||
217 | 217 | ||
218 | overlays = mapAttrs (_name: path: mkOverlay path) overlayPaths; | 218 | overlays = mapAttrs (_name: path: mkOverlay path) overlayPaths; |
219 | 219 | ||
220 | packages = forAllSystems (system: systemPkgs: nixImport rec { dir = ./tools; _import = _path: name: import "${toString dir}/${name}" ({ inherit system; } // inputs); }); | ||
221 | |||
220 | # packages = mapAttrs (_name: filterAttrs (_name: isDerivation)) packages; | 222 | # packages = mapAttrs (_name: filterAttrs (_name: isDerivation)) packages; |
221 | # packages' = mapAttrs (_name: filterAttrs (_name: value: !(isDerivation value))) packages; | 223 | # packages' = mapAttrs (_name: filterAttrs (_name: value: !(isDerivation value))) packages; |
222 | 224 | ||
@@ -3,11 +3,9 @@ let | |||
3 | pkgs = self.legacyPackages.${system}; | 3 | pkgs = self.legacyPackages.${system}; |
4 | utils = import ./utils { inherit (nixpkgs) lib; }; | 4 | utils = import ./utils { inherit (nixpkgs) lib; }; |
5 | inherit (utils) nixImport; | 5 | inherit (utils) nixImport; |
6 | |||
7 | tools = nixImport rec { dir = ./tools; _import = _path: name: import "${toString dir}/${name}" inputs; }; | ||
8 | in pkgs.mkShell { | 6 | in pkgs.mkShell { |
9 | name = "nixos"; | 7 | name = "nixos"; |
10 | nativeBuildInputs = builtins.attrValues tools ++ (with pkgs; [ | 8 | nativeBuildInputs = builtins.attrValues self.packages.${system} ++ (with pkgs; [ |
11 | sops | 9 | sops |
12 | wireguard-tools | 10 | wireguard-tools |
13 | gup | 11 | gup |
diff --git a/tools/ca/ca/__main__.py b/tools/ca/ca/__main__.py index 6615da55..bfaee63a 100644 --- a/tools/ca/ca/__main__.py +++ b/tools/ca/ca/__main__.py | |||
@@ -32,6 +32,7 @@ import subprocess | |||
32 | import json | 32 | import json |
33 | from leapseconddata import LeapSecondData | 33 | from leapseconddata import LeapSecondData |
34 | from collections.abc import Iterable | 34 | from collections.abc import Iterable |
35 | import ipaddress | ||
35 | 36 | ||
36 | 37 | ||
37 | class KeyType(Enum): | 38 | class KeyType(Enum): |
@@ -208,8 +209,12 @@ def mv_bak(path): | |||
208 | break | 209 | break |
209 | bak_path = path.parent / f'{path.name}.bak{n}' | 210 | bak_path = path.parent / f'{path.name}.bak{n}' |
210 | 211 | ||
211 | logger.warn('Renaming ‘%s’ to ‘%s’...', path, bak_path) | 212 | try: |
212 | path.rename(bak_path) | 213 | path.rename(bak_path) |
214 | except FileNotFoundError: | ||
215 | pass | ||
216 | else: | ||
217 | logger.warn('Renamed ‘%s’ to ‘%s’...', path, bak_path) | ||
213 | 218 | ||
214 | def tai64nint(dt): | 219 | def tai64nint(dt): |
215 | global leapsecond_data | 220 | global leapsecond_data |
@@ -262,6 +267,44 @@ def write_genkey(key_type, sops, keyfile): | |||
262 | 267 | ||
263 | return key | 268 | return key |
264 | 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 | |||
265 | def initca(ca_cert, ca_key, key_type, subject, clock_skew, validity, sops): | 308 | def initca(ca_cert, ca_key, key_type, subject, clock_skew, validity, sops): |
266 | global logger | 309 | global logger |
267 | 310 | ||
@@ -273,14 +316,8 @@ def initca(ca_cert, ca_key, key_type, subject, clock_skew, validity, sops): | |||
273 | if not key_type.aligned(key): | 316 | if not key_type.aligned(key): |
274 | logger.warn('Private key ‘%s’ does not align with requested type %s', ca_key, key_type) | 317 | logger.warn('Private key ‘%s’ does not align with requested type %s', ca_key, key_type) |
275 | 318 | ||
276 | try: | 319 | mv_bak(ca_key) |
277 | mv_bak(ca_key) | 320 | mv_bak(ca_cert) |
278 | except FileNotFoundError: | ||
279 | pass | ||
280 | try: | ||
281 | mv_bak(ca_cert) | ||
282 | except FileNotFoundError: | ||
283 | pass | ||
284 | 321 | ||
285 | raise FileNotFoundError(f'Key does not align with requested type: {ca_key}') | 322 | raise FileNotFoundError(f'Key does not align with requested type: {ca_key}') |
286 | except FileNotFoundError: | 323 | except FileNotFoundError: |
@@ -295,9 +332,14 @@ def initca(ca_cert, ca_key, key_type, subject, clock_skew, validity, sops): | |||
295 | logger.debug('Generating new certificate...') | 332 | logger.debug('Generating new certificate...') |
296 | 333 | ||
297 | now = datetime.utcnow() | 334 | now = datetime.utcnow() |
298 | name = x509.Name([ | 335 | name = None |
299 | x509.NameAttribute(NameOID.COMMON_NAME, subject.relative) | 336 | try: |
300 | ]) | 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 | ]) | ||
301 | 343 | ||
302 | cert = x509.CertificateBuilder().subject_name( | 344 | cert = x509.CertificateBuilder().subject_name( |
303 | name | 345 | name |
@@ -342,24 +384,28 @@ def signcsr(ca_cert, ca_key, clock_skew, validity, subject, alternative_name, ke | |||
342 | csr_bytes = csr | 384 | csr_bytes = csr |
343 | 385 | ||
344 | csr = x509.load_pem_x509_csr(csr_bytes) | 386 | csr = x509.load_pem_x509_csr(csr_bytes) |
387 | name = None | ||
345 | if not subject: | 388 | if not subject: |
346 | common_name_attrs = csr.subject.get_attributes_for_oid(NameOID.COMMON_NAME) | 389 | name = csr.subject |
347 | if len(common_name_attrs) != 1: | 390 | else: |
348 | raise InvalidParamsError('Invalid name structure in CSR') | 391 | try: |
349 | subject = common_name_attrs[0].value.lower() | 392 | name = x509.Name.from_rfc4514_string(subject) |
350 | logger.warn('Using subject common name from csr: %s', subject) | 393 | logger.info('‘%s’ interpreted as directory name: %s', subject, name) |
351 | name = x509.Name([ | 394 | except ValueError: |
352 | x509.NameAttribute(NameOID.COMMON_NAME, subject) | 395 | name = x509.Name([ |
353 | ]) | 396 | x509.NameAttribute(NameOID.COMMON_NAME, subject) |
397 | ]) | ||
354 | 398 | ||
355 | if not ignore_alternative_names: | 399 | if not ignore_alternative_names: |
356 | try: | 400 | try: |
357 | ext = csr.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME) | 401 | ext = csr.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME) |
358 | csr_alt_names = ext.value.get_values_for_type(x509.DNSName) | 402 | csr_alt_names = set(ext.value) |
359 | logger.warn('Using alternative names from csr: %s', csr_alt_names) | 403 | logger.warn('Using alternative names from csr: %s', csr_alt_names) |
360 | alternative_name = list(set(alternative_name) | set(csr_alt_names)) | 404 | alternative_name = set(to_dn(alternative_name)) | csr_alt_names |
361 | except ExtensionNotFound: | 405 | except ExtensionNotFound: |
362 | pass | 406 | pass |
407 | else: | ||
408 | alternative_name = to_dn(alternative_name) | ||
363 | 409 | ||
364 | ca_key = load_key(ca_key) | 410 | ca_key = load_key(ca_key) |
365 | with open(ca_cert, 'rb') as fh: | 411 | with open(ca_cert, 'rb') as fh: |
@@ -397,9 +443,7 @@ def signcsr(ca_cert, ca_key, clock_skew, validity, subject, alternative_name, ke | |||
397 | 443 | ||
398 | if alternative_name: | 444 | if alternative_name: |
399 | cert = cert.add_extension( | 445 | cert = cert.add_extension( |
400 | x509.SubjectAlternativeName( | 446 | x509.SubjectAlternativeName(alternative_name), |
401 | list(map(x509.DNSName, alternative_name)) | ||
402 | ), | ||
403 | False | 447 | False |
404 | ) | 448 | ) |
405 | 449 | ||
@@ -407,10 +451,7 @@ def signcsr(ca_cert, ca_key, clock_skew, validity, subject, alternative_name, ke | |||
407 | 451 | ||
408 | output = output.with_suffix('.crt') | 452 | output = output.with_suffix('.crt') |
409 | 453 | ||
410 | try: | 454 | mv_bak(output) |
411 | mv_bak(output) | ||
412 | except FileNotFoundError: | ||
413 | pass | ||
414 | with umask(0o0133), atomic_write(output, overwrite=False, mode='wb') as cf: | 455 | with umask(0o0133), atomic_write(output, overwrite=False, mode='wb') as cf: |
415 | logger.info('Writing new certificate to ‘%s’...', output) | 456 | logger.info('Writing new certificate to ‘%s’...', output) |
416 | cf.write(cert.public_bytes(serialization.Encoding.PEM)) | 457 | cf.write(cert.public_bytes(serialization.Encoding.PEM)) |
@@ -429,26 +470,28 @@ def new_client(ca_cert, ca_key, key_type, clock_skew, validity, subject, alterna | |||
429 | if not key_type.aligned(key): | 470 | if not key_type.aligned(key): |
430 | logger.warn('Private key ‘%s’ does not align with requested type %s', key_file, key_type) | 471 | logger.warn('Private key ‘%s’ does not align with requested type %s', key_file, key_type) |
431 | 472 | ||
432 | try: | 473 | mv_bak(key_file) |
433 | mv_bak(key_file) | 474 | mv_bak(cert_file) |
434 | except FileNotFoundError: | ||
435 | pass | ||
436 | try: | ||
437 | mv_bak(cert_file) | ||
438 | except FileNotFoundError: | ||
439 | pass | ||
440 | 475 | ||
441 | raise FileNotFoundError(f'Key does not align with requested type: {key_file}') | 476 | raise FileNotFoundError(f'Key does not align with requested type: {key_file}') |
442 | except FileNotFoundError: | 477 | except FileNotFoundError: |
443 | key = write_genkey(key_type, sops, key_file) | 478 | key = write_genkey(key_type, sops, key_file) |
444 | 479 | ||
445 | csr = x509.CertificateSigningRequestBuilder().subject_name(x509.Name([ | 480 | name = None |
446 | x509.NameAttribute(NameOID.COMMON_NAME, subject) | 481 | try: |
447 | ])) | 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 | |||
448 | if alternative_name: | 491 | if alternative_name: |
449 | csr = csr.add_extension( | 492 | csr = csr.add_extension( |
450 | x509.SubjectAlternativeName( | 493 | x509.SubjectAlternativeName( |
451 | list(map(x509.DNSName, alternative_name)) | 494 | to_dn(alternative_name) |
452 | ), | 495 | ), |
453 | False | 496 | False |
454 | ) | 497 | ) |
@@ -553,54 +596,54 @@ def main(): | |||
553 | 596 | ||
554 | 597 | ||
555 | parser = argparse.ArgumentParser(prog='ca', formatter_class=argparse.ArgumentDefaultsHelpFormatter) | 598 | parser = argparse.ArgumentParser(prog='ca', formatter_class=argparse.ArgumentDefaultsHelpFormatter) |
556 | parser.add_argument('--verbosity', dest='log_level', action='append', type=int) | 599 | parser.add_argument('--verbosity', dest='log_level', action='append', type=int, help='Numeric verbosity') |
557 | parser.add_argument('--verbose', '-v', dest='log_level', action='append_const', const=1) | 600 | parser.add_argument('--verbose', '-v', dest='log_level', action='append_const', const=1, help='Increase verbosity') |
558 | parser.add_argument('--quiet', '-q', dest='log_level', action='append_const', const=-1) | 601 | parser.add_argument('--quiet', '-q', dest='log_level', action='append_const', const=-1, help='Decrease verbosity') |
559 | subparsers = parser.add_subparsers(help='Subcommands', required=True) | 602 | subparsers = parser.add_subparsers(help='Subcommands', required=True) |
560 | 603 | ||
561 | subparser = subparsers.add_parser('init', aliases=['initca', 'init-ca', 'ca'], formatter_class=argparse.ArgumentDefaultsHelpFormatter) | 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") |
562 | subparser.add_argument('--ca-cert', type=Path, default=Path('ca.crt')) | 605 | subparser.add_argument('--ca-cert', type=Path, default=Path('ca.crt'), help='Path to file containing CA certificate') |
563 | subparser.add_argument('--ca-key', type=Path, default=Path('ca.key')) | 606 | subparser.add_argument('--ca-key', type=Path, default=Path('ca.key'), help='Path to file containing CA private key') |
564 | subparser.add_argument('--key-type', type=KeyType.from_string, choices=list(KeyType), default=KeyType.ED448.value) | 607 | subparser.add_argument('--key-type', type=KeyType.from_string, choices=list(KeyType), default=KeyType.ED448.value, help='Type of private key to generate') |
565 | subparser.add_argument('--clock-skew', metavar='DURATION', type=duration, default=timedelta(minutes=5)) | 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') |
566 | subparser.add_argument('--validity', metavar='DURATION', type=duration, default=timedelta(days=ceil(365.2425*10))) | 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') |
567 | subparser.add_argument('--sops', '--no-sops', action=BooleanAction, default=True) | 610 | subparser.add_argument('--sops', '--no-sops', action=BooleanAction, default=True, help='Encrypt private key using SOPS') |
568 | subparser.add_argument('--subject', metavar='FQDN', type=ValidFQDN, required=True) | 611 | subparser.add_argument('--subject', metavar='DN', type=str, required=True, help='Subject name') |
569 | subparser.set_defaults(cmd=initca) | 612 | subparser.set_defaults(cmd=initca) |
570 | 613 | ||
571 | subparser = subparsers.add_parser('sign', aliases=['signcsr', 'sign-csr'], formatter_class=argparse.ArgumentDefaultsHelpFormatter) | 614 | subparser = subparsers.add_parser('sign', aliases=['signcsr', 'sign-csr'], formatter_class=argparse.ArgumentDefaultsHelpFormatter, description='Sign an existing CSR') |
572 | subparser.add_argument('--ca-cert', type=Path, default=Path('ca.crt')) | 615 | subparser.add_argument('--ca-cert', type=Path, default=Path('ca.crt'), help='Path to file containing CA certificate') |
573 | subparser.add_argument('--ca-key', type=Path, default=Path('ca.key')) | 616 | subparser.add_argument('--ca-key', type=Path, default=Path('ca.key'), help='Path to file containing CA private key') |
574 | subparser.add_argument('--clock-skew', metavar='DURATION', type=duration, default=timedelta(minutes=5)) | 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') |
575 | subparser.add_argument('--validity', metavar='DURATION', type=duration, default=timedelta(days=ceil(365.2425*10))) | 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') |
576 | subparser.add_argument('--subject', metavar='CN', type=str, required=False) | 619 | subparser.add_argument('--subject', metavar='DN', type=str, required=False, help='Override subject name') |
577 | subparser.add_argument('--ignore-alternative-names', '--no-ignore-alternative-names', action=BooleanAction, default=True) | 620 | subparser.add_argument('--ignore-alternative-names', '--no-ignore-alternative-names', action=BooleanAction, default=True, help='Ignore subject alternative names provided in CSR') |
578 | subparser.add_argument('--key-usage', metavar='KEY_USAGE', type=SupportedKeyUsage, action=ExtendAction, default=[SupportedKeyUsage.CLIENT_AUTH]) | 621 | subparser.add_argument('--key-usage', metavar='KEY_USAGE', type=SupportedKeyUsage, action=ExtendAction, default=[SupportedKeyUsage.CLIENT_AUTH], help='Allowed key usages') |
579 | subparser.add_argument('--alternative-name', metavar='CN', type=str, action='append') | 622 | subparser.add_argument('--alternative-name', metavar='CN', type=str, action='append', help='Subject alternative names') |
580 | subparser.add_argument('--output', type=Path, required=True) | 623 | subparser.add_argument('--output', type=Path, required=True, help='Output path') |
581 | subparser.add_argument('csr', metavar='FILE', type=argparse.FileType(mode='rb')) | 624 | subparser.add_argument('csr', metavar='FILE', type=argparse.FileType(mode='rb'), help='Path to file containing CSR') |
582 | subparser.set_defaults(cmd=signcsr) | 625 | subparser.set_defaults(cmd=signcsr) |
583 | 626 | ||
584 | subparser = subparsers.add_parser('new-client', aliases=['new', 'new-client', 'client'], formatter_class=argparse.ArgumentDefaultsHelpFormatter) | 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') |
585 | subparser.add_argument('--ca-cert', type=Path, default=Path('ca.crt')) | 628 | subparser.add_argument('--ca-cert', type=Path, default=Path('ca.crt'), help='Path to file containing CA certificate') |
586 | subparser.add_argument('--ca-key', type=Path, default=Path('ca.key')) | 629 | subparser.add_argument('--ca-key', type=Path, default=Path('ca.key'), help='Path to file containing CA private key') |
587 | subparser.add_argument('--key-type', type=KeyType.from_string, choices=list(KeyType), default=KeyType.ED25519.value) | 630 | subparser.add_argument('--key-type', type=KeyType.from_string, choices=list(KeyType), default=KeyType.ED25519.value, help='Type of private key to generate') |
588 | subparser.add_argument('--clock-skew', metavar='DURATION', type=duration, default=timedelta(minutes=5)) | 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') |
589 | subparser.add_argument('--validity', metavar='DURATION', type=duration, default=timedelta(days=ceil(365.2425*10))) | 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') |
590 | subparser.add_argument('--sops', '--no-sops', action=BooleanAction, default=True) | 633 | subparser.add_argument('--sops', '--no-sops', action=BooleanAction, default=True, help='Encrypt private key using SOPS') |
591 | subparser.add_argument('--subject', metavar='CN', type=str, required=True) | 634 | subparser.add_argument('--subject', metavar='DN', type=str, required=True, help='Subject name') |
592 | subparser.add_argument('--key-usage', metavar='KEY_USAGE', type=SupportedKeyUsage, action=ExtendAction, default=[SupportedKeyUsage.CLIENT_AUTH]) | 635 | subparser.add_argument('--key-usage', metavar='KEY_USAGE', type=SupportedKeyUsage, action=ExtendAction, default=[SupportedKeyUsage.CLIENT_AUTH], help='Allowed key usages') |
593 | subparser.add_argument('--alternative-name', metavar='CN', type=str, action='append') | 636 | subparser.add_argument('--alternative-name', metavar='CN', type=str, action='append', help='Subject alternative names') |
594 | subparser.add_argument('--output', type=Path, required=True) | 637 | subparser.add_argument('--output', type=Path, required=True, help='Output path') |
595 | subparser.set_defaults(cmd=new_client) | 638 | subparser.set_defaults(cmd=new_client) |
596 | 639 | ||
597 | subparser = subparsers.add_parser('pkcs12', aliases=['p12', 'pfx'], formatter_class=argparse.ArgumentDefaultsHelpFormatter) | 640 | subparser = subparsers.add_parser('pkcs12', aliases=['p12', 'pfx'], formatter_class=argparse.ArgumentDefaultsHelpFormatter, description='Convert existing certificate and private key to PKCS#12 format') |
598 | subparser.add_argument('--random-password', '--no-random-password', action=BooleanAction, default=True) | 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') |
599 | subparser.add_argument('--random-password-length', type=int, default=12) | 642 | subparser.add_argument('--random-password-length', type=int, default=12, help='Number of words in random passphrase') |
600 | subparser.add_argument('--weak-encryption', '--no-weak-encryption', action=BooleanAction, default=False) | 643 | subparser.add_argument('--weak-encryption', '--no-weak-encryption', action=BooleanAction, default=False, help='Use weak, but more compatible, encryption') |
601 | subparser.add_argument('--temporary-output', '--no-temporary-output', action=BooleanAction, default=True) | 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') |
602 | subparser.add_argument('--output', type=Path) | 645 | subparser.add_argument('--output', type=Path, help='Output path') |
603 | subparser.add_argument('filename', metavar='BASENAME', type=Path) | 646 | subparser.add_argument('filename', metavar='BASENAME', type=Path, help='Input path') |
604 | subparser.set_defaults(cmd=to_pkcs12) | 647 | subparser.set_defaults(cmd=to_pkcs12) |
605 | 648 | ||
606 | args = parser.parse_args() | 649 | args = parser.parse_args() |