#!/usr/bin/python3 """Manage the router's PKI, which the router's directory holds, from the command line, with vpnrouter_pki. status shows the root, the issuing CAs and the warnings; list the certificates the router issued. issue signs a CSR, or makes a key and writes a PFX file whose password is read from the first line of standard input. ca csr prints the CSR of an issuing CA that waits for the offline root, with the openssl extensions to sign it with, and ca import takes the signed certificate. root export-key writes the root key encrypted with the passphrase read from standard input, root remove-key removes it from the router, root import-key puts it back, and root import-crl takes a root CRL signed offline. maintain signs the stale CRLs again; vpn-router-pki.service runs it daily. A change that needs vpn-router-setup restarts it without waiting. The answer is text for people on standard output, and a failure is a message on standard error. With --json the answer is JSON on standard output, and a failure is {"error": ...} there too. The exit status says what failed: 2 the request is invalid, 3 something it names does not exist, 4 the state does not allow it, 1 anything else. -h or --help shows the commands and their options. vpn-router-ca [--json] status vpn-router-ca [--json] list [--ca NAME] vpn-router-ca [--json] issue --ca NAME --template NAME --csr FILE vpn-router-ca [--json] issue --ca NAME --template NAME --subject NAME [--san NAME ...] --pfx FILE (password on stdin) vpn-router-ca [--json] revoke SERIAL [--reason REASON] vpn-router-ca [--json] maintain vpn-router-ca [--json] ca csr NAME vpn-router-ca [--json] ca import NAME FILE vpn-router-ca [--json] root export-key --out FILE (passphrase on stdin) vpn-router-ca [--json] root remove-key [--yes] vpn-router-ca [--json] root import-key FILE (passphrase on stdin, an empty line for none) vpn-router-ca [--json] root import-crl FILE """ import argparse import base64 import json import os import pathlib import subprocess import sys sys.path.insert(0, '/usr/lib/vpn-router') import ldap import vpnrouter import vpnrouter_pki from vpnrouter_pki import Failure class Parser(argparse.ArgumentParser): """An argument parser whose errors end with status 2.""" def error(self, message): print(f'{self.prog}: {message}; see {self.prog} --help', file=sys.stderr) sys.exit(2) def secret(): """The first line of standard input.""" return sys.stdin.readline().rstrip('\r\n') def restart_setup(): subprocess.run(['systemctl', 'restart', '--no-block', 'vpn-router-setup.service'], capture_output=True, text=True) def show_status(document): root = document['root'] if root is None: print('Root CA: none; vpn-router-setup creates it once a platform is chosen') else: print(f'Root CA: {root["subject"]}, expires {root["expires"]}, key {"on the router" if root["key"] else "offline"}' f', CRL until {root["crl_next_update"] or "none"}') for authority in document['authorities']: line = f'Issuing CA {authority["name"]}: {authority["common_name"]}, {authority["state"]}' if authority['expires']: line += f', expires {authority["expires"]}, CRL until {authority["crl_next_update"] or "none"}' line += f', templates {", ".join(authority["templates"])}' if authority['acme_url']: line += f', ACME {authority["acme_url"]}' print(line) for retired in document['retired']: print(f'Retired CA {retired["name"]}: {retired["retired"]}') for warning in document['warnings']: print(f'Warning: {warning}') def run(options): """Do the command of the options; the answer as a document, and a text for people.""" cfg = vpnrouter.load_config() if options.command == 'status': document = vpnrouter_pki.status(cfg) return document, None if options.command == 'list': found = [entry for entry in vpnrouter_pki.certificates() if options.ca in (None, entry['ca'])] text = '\n'.join(f'{entry["serial"]} {entry["ca"]} {entry["kind"]} {entry["subject"]} {entry["state"]} ' f'{entry["expires"]}' for entry in found) return {'certificates': found}, text with vpnrouter_pki.locked(): if options.command == 'issue': if options.csr: document = vpnrouter_pki.issue(cfg, options.ca, options.template, csr=pathlib.Path(options.csr).read_text(encoding='ascii')) return document, document['certificate'] + document['chain'] if not options.subject or not options.pfx: raise Failure('give --csr, or --subject and --pfx', 2) document = vpnrouter_pki.issue(cfg, options.ca, options.template, subject=options.subject, sans=options.san or [], password=secret()) out = pathlib.Path(options.pfx) with open(out, 'xb') as handle: handle.write(base64.b64decode(document['pfx'])) return {'serial': document['serial'], 'pfx': str(out)}, f'Certificate {document["serial"]} written to {out}.' if options.command == 'revoke': document = vpnrouter_pki.revoke(cfg, options.serial, options.reason) if document['reissue']: restart_setup() return document, f'Certificate {document["serial"]} revoked' + \ ('; vpn-router-setup issues a new one.' if document['reissue'] else '.') if options.command == 'maintain': due = vpnrouter_pki.maintain(cfg) if due: restart_setup() return {'renewal': due}, 'CRLs checked' + ('; vpn-router-setup renews the router certificates.' if due else '.') if options.command == 'ca' and options.action == 'csr': document = vpnrouter_pki.csr(cfg, options.name) return document, document['csr'] + '\n' + document['extensions'] if options.command == 'ca': document = vpnrouter_pki.import_ca(cfg, options.name, pathlib.Path(options.file).read_text(encoding='ascii')) restart_setup() return document, f'Issuing CA {options.name} has its certificate, which expires {document["expires"]}.' if options.action == 'export-key': data = vpnrouter_pki.export_root_key(secret()) out = pathlib.Path(options.out) with open(out, 'xb') as handle: handle.write(data) return {'exported': str(out)}, f'Root key written to {out}, encrypted.' if options.action == 'remove-key': if not options.yes: print('Remove the root key from the router? Keep an exported copy first. Type yes: ', end='', file=sys.stderr, flush=True) if secret().strip() != 'yes': return {'removed': False}, 'Nothing removed.' vpnrouter_pki.remove_root_key() return {'removed': True}, 'Root key removed; new issuing CAs now wait for an offline signature.' if options.action == 'import-key': vpnrouter_pki.import_root_key(pathlib.Path(options.file).read_bytes(), secret() or None) restart_setup() return {'imported': True}, 'Root key back on the router.' document = vpnrouter_pki.import_root_crl(pathlib.Path(options.file).read_bytes()) return document, f'Root CRL taken, valid until {document["next_update"]}.' def main(argv): parser = Parser(prog='vpn-router-ca', description="Manage the router's PKI.") parser.add_argument('--json', action='store_true', help='answer in JSON') commands = parser.add_subparsers(dest='command', required=True, parser_class=Parser) commands.add_parser('status', help='show the root, the issuing CAs and the warnings') lister = commands.add_parser('list', help='list the certificates the router issued') lister.add_argument('--ca', help='only those of this issuing CA') issuer = commands.add_parser('issue', help='issue a certificate from a CSR, or with a new key into a PFX file') issuer.add_argument('--ca', required=True, help='the issuing CA') issuer.add_argument('--template', required=True, help='the certificate template') issuer.add_argument('--csr', help='the PEM CSR to sign') issuer.add_argument('--subject', help='the common name, without --csr') issuer.add_argument('--san', action='append', help='a subject alternative name, without --csr; repeat for more') issuer.add_argument('--pfx', help='the PFX file to write, without --csr; the password is read from standard input') revoker = commands.add_parser('revoke', help='revoke a certificate the router issued') revoker.add_argument('serial', help='the serial number, hexadecimal') revoker.add_argument('--reason', choices=vpnrouter_pki.REASONS, help='the reason, unspecified by default') commands.add_parser('maintain', help='sign the stale CRLs again') authority = commands.add_parser('ca', help='an issuing CA that waits for the offline root') actions = authority.add_subparsers(dest='action', required=True, parser_class=Parser) actions.add_parser('csr', help='print its CSR and the openssl extensions').add_argument('name') importer = actions.add_parser('import', help='take its certificate signed by the root') importer.add_argument('name') importer.add_argument('file') root = commands.add_parser('root', help='the root CA') root_actions = root.add_subparsers(dest='action', required=True, parser_class=Parser) exporter = root_actions.add_parser('export-key', help='write the root key encrypted with the passphrase from stdin') exporter.add_argument('--out', required=True, help='the file to write') remover = root_actions.add_parser('remove-key', help='remove the root key from the router') remover.add_argument('--yes', action='store_true', help='do not ask') root_actions.add_parser('import-key', help='put the root key back').add_argument('file') root_actions.add_parser('import-crl', help='take a root CRL signed offline').add_argument('file') options = parser.parse_args(argv) os.umask(0o077) try: document, text = run(options) except Failure as failure: code, message = failure.status, str(failure) except OSError as exc: code, message = 1, str(exc) except ldap.LDAPError as exc: code, message = 1, f'the directory, which holds the PKI, failed: {exc}' except SystemExit as exc: code, message = 1, str(exc.code) else: if options.json: print(json.dumps(document, indent=2)) elif text is None: show_status(document) elif text: print(text.rstrip('\n')) return 0 if options.json: print(json.dumps({'error': message}, indent=2)) else: print(f'vpn-router-ca: {message}', file=sys.stderr) return code if __name__ == '__main__': sys.exit(main(sys.argv[1:]))