# ==========================================================================
#         ____            _                     _____           _
#        / ___| _   _ ___| |_ ___ _ __ ___     |_   _|__   ___ | |___
#        \___ \| | | / __| __/ _ \ '_ ` _ \ _____| |/ _ \ / _ \| / __|
#         ___) | |_| \__ \ ||  __/ | | | | |_____| | (_) | (_) | \__ \
#        |____/ \__, |___/\__\___|_| |_| |_|     |_|\___/ \___/|_|___/
#               |___/
#                             --- System-Tools ---
#                  https://www.nntb.no/~dreibh/system-tools/
# ==========================================================================
#
# X.509 CA and Certificate Helper Library
# Copyright (C) 2015-2026 by Thomas Dreibholz
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
# Contact: thomas.dreibholz@gmail.com

import ipaddress
import os
import re
import shlex
import shutil
import socket
import subprocess
import sys

# This library needs at least Python 3.10:
MIN_PYTHON = (3, 10)
if sys.version_info < MIN_PYTHON:
   sys.exit('Python %s.%s or later is required!' % MIN_PYTHON)

# This library also requires the netifaces package:
try:
   import netifaces
except ImportError:
   sys.exit('The Python netifaces package is required!')

from typing import Final
from enum   import Enum


# Certificate Types:
class CertificateType(Enum):
   RootCA          = 1
   IntermediateCA  = 2
   LeafCA          = 3
   Server          = 4
   Client          = 5
   User            = 6

# Key algorithm:
class KeyAlgorithm(Enum):
   RSA     = 1,
   EC      = 2,
   ED25519 = 3,
   ED448   = 4

# Some defaults:
DefaultKeyAlgorithm  : Final[KeyAlgorithm] = KeyAlgorithm.RSA
DefaultCAKeyLength   : Final[int] = 16384               # RSA only!
DefaultCertKeyLength : Final[int] = 16384               # RSA only!
DefaultECCurve       : Final[str] = 'brainpoolP512r1'   # EC only!


# ***** TEST ONLY *******************************
# These settings are for fast testing only:
# DefaultCAKeyLength   : Final[int] = 1024
# DefaultCertKeyLength : Final[int] = 1024
# ***********************************************

# Enable verbose logging for debugging here:
VerboseMode : bool = True



# ###### Execute command ####################################################
def execute(command : str, mayFail : bool = False) -> int:
   result : subprocess.CompletedProcess[bytes]
   try:
      if VerboseMode:
         sys.stdout.write('\x1b[37m' + command + '\x1b[0m\n')
      result = subprocess.run(command, shell = True)
   except Exception as e:
      sys.stderr.write('FAILED COMMAND:\n' + command + '\n')
      sys.exit(1)
   if not mayFail:
      assert result.returncode == 0
   return result.returncode


# ###### Make "subjectAltName" string #######################################
RE_USEREMAIL : Final[re.Pattern[str]] = \
   re.compile(r'^(.*)( <)([a-zA-Z0-9. _%+-]+@[a-zA-Z0-9. -]+\.[a-zA-Z]{2,})(>)$')
def prepareSubjectAltName(certType : CertificateType,
                          name     : str,
                          hint     : str | None) -> tuple[str,str]:

   # ====== Server or client cerfificate ====================================
   if (certType == CertificateType.Server) or (certType == CertificateType.Client):

      # ====== Prepare subjectAltName: current hostname+all addresses =======
      subjectAltName : str                                                = 'DNS:' + name
      addresses      : set[ipaddress.IPv4Address | ipaddress.IPv6Address] = set()

      # ====== Get local addresses ==========================================
      if (hint is None) or (hint == 'LOCAL'):
         # ------ Add FQDN of current hostname ------------------------------
         fqdn : str = socket.getfqdn()
         if ((fqdn != name) and (fqdn != 'localhost')):
            subjectAltName = subjectAltName + ',DNS:' + fqdn

         # ------ Add all IP addresses --------------------------------------
         interfaces = netifaces.interfaces()
         for family in [ netifaces.AF_INET, netifaces.AF_INET6 ]:
            for interface in interfaces:
               interfaceAddresses = netifaces.ifaddresses(interface).get(family)
               if interfaceAddresses:
                  for interfaceAddress in interfaceAddresses:
                     address = ipaddress.ip_address(interfaceAddress['addr'])
                     if (not address.is_link_local) and (not address.is_loopback):
                        addresses.add(address)

      # ====== Look up addresses ============================================
      elif hint == 'LOOKUP':
         print('Resolving ' + name + ' ...\n')
         try:
            resolveResults = socket.getaddrinfo(name, 80)
         except socket.gaierror as e:
            sys.stderr.write('ERROR: Resolving of ' + name + ' failed: ' + str(e) + '\n')
            sys.exit(1)
         for resolveEntry in resolveResults:
            address = ipaddress.ip_address(resolveEntry[4][0])
            addresses.add(address)

      # ====== Append IP addresses to subjectAltName ========================
      addresses = set(sorted(addresses, key = lambda item: (item.version, int(item))))
      for address in addresses:
         subjectAltName = subjectAltName + ',IP:' + str(address)

      return ( name, subjectAltName )

   # ====== User certificate ================================================
   elif certType == CertificateType.User:

      match = RE_USEREMAIL.match(name)
      if match:
         name           = match.group(1)
         subjectAltName = 'email:' + match.group(3)
         return ( name, subjectAltName )

   return ( name, '' )


# ###### Make subject string for user #######################################
RE_TITLE : Final[re.Pattern[str]] = re.compile(r'^([A-Z][a-z]+\.)$')
RE_EMAIL : Final[re.Pattern[str]] = re.compile(r'^<[^<>]+@[^<>]+>$')
def prepareUserSubject(name : str) -> str:
   parts    : list[str] = name.split(' ')
   titles   : list[str] = [ ]
   names    : list[str] = [ ]
   initials : list[str] = [ ]
   email    : str       = ''
   for part in parts:
      if RE_TITLE.match(part):
         titles.append(part)
      elif RE_EMAIL.match(part):
          email = part
      else:
         names.append(part)
         initials.append(part[0] + '.')

   if (len(names) < 2) or (len(initials) < 2):
      sys.stderr.write(f'ERROR: Unsupported syntax for user: {name}\n')
      sys.exit(1)

   subject = ''
   if len(titles) > 0:
      subject += '/title=' + ' '.join(titles)
   subject += '/givenName=' + ' '.join(names[0:len(names) - 1])
   subject += '/initials='  + ' '.join(initials[0:len(initials) - 1])
   subject += '/surname='   + names[len(names) - 1]
   if email != '':
      subject += '/emailAddress=' + email

   print("==> " + subject)
   return subject


# ###### CA #################################################################
GlobalCRLDictionary      : dict[str, int] = { }
DefaultGlobalCRLFileName : Final[str]     = 'Test-Combined-CRLs.crl'

class CA:

   # ###### Constructor #####################################################
   def __init__(self,
                mainDirectory     : str,
                name              : str,
                parentCA          : 'CA | None',
                subject           : str,
                certType          : CertificateType,
                days              : int = 10 * 365,
                keyAlgorithm      : KeyAlgorithm = DefaultKeyAlgorithm,
                keyLength         : int = DefaultCAKeyLength,
                ecCurve           : str = DefaultECCurve,
                globalCRLFileName : str = DefaultGlobalCRLFileName):

      safeName               : Final[str]             = re.sub(r'[^a-zA-Z0-9+-\.]', '_', name)
      self.MainDirectory     : Final[str]             = os.path.abspath(mainDirectory)
      self.Directory         : Final[str]             = os.path.join(self.MainDirectory, safeName)
      self.GlobalCRLFileName : Final[str]             = os.path.join(self.MainDirectory, globalCRLFileName)
      self.Subject           : Final[str]             = subject
      self.CAName            : Final[str]             = name
      self.CertType          : Final[CertificateType] = certType
      self.Extension         : str
      if self.CertType == CertificateType.RootCA:
         self.Extension = 'v3_ca'
      elif self.CertType == CertificateType.IntermediateCA:
         self.Extension = 'v3_intermediate_ca'
      elif self.CertType == CertificateType.LeafCA:
         self.Extension = 'v3_leaf_ca'
      else:
         raise Exception('Invalid certificate type')

      self.ParentCA          : 'CA' | None         = parentCA
      self.DefaultDays       : Final[int]          = days

      self.KeyAlgorithm      : Final[KeyAlgorithm] = keyAlgorithm
      self.KeyLength         : Final[int]          = keyLength
      self.ECCurve           : Final[str]          = ecCurve

      self.CertsDirectory    : Final[str] = os.path.join(self.Directory, 'certs')
      self.NewCertsDirectory : Final[str] = os.path.join(self.Directory, 'newcerts')
      self.CRLDirectory      : Final[str] = os.path.join(self.Directory, 'crl')
      self.PrivateDirectory  : Final[str] = os.path.join(self.Directory, 'private')

      self.IndexFileName     : Final[str] = os.path.join(self.Directory, 'index.txt')
      self.SerialFileName    : Final[str] = os.path.join(self.Directory, 'serial')
      self.CRLNumberFileName : Final[str] = os.path.join(self.Directory, 'crlnumber')
      self.ConfigFileName    : Final[str] = os.path.join(self.Directory, safeName + '.conf')

      self.KeyFileName       : Final[str] = os.path.join(self.PrivateDirectory, safeName + '.key')
      self.PasswordFileName  : Final[str] = os.path.join(self.PrivateDirectory, safeName + '.password')
      self.CertFileName      : Final[str] = os.path.join(self.CertsDirectory,   safeName + '.crt')
      self.CRLFileName       : Final[str] = os.path.join(self.CRLDirectory,     safeName + '.crl')

      if VerboseMode or not os.path.isfile(self.CertFileName):
         sys.stdout.write('\x1b[34mCreating CA ' + name + ' ...\x1b[0m\n')

      # ====== Create directories, if not already existing ==================
      for directory in [
            self.CertsDirectory,      # For issued certificates
            self.NewCertsDirectory,   # Certificates to be signed
            self.CRLDirectory          # Certificates Revocation Lists (CRLs)
         ]:
         os.makedirs(os.path.join(self.Directory, directory),
                     mode     = 0o755,
                     exist_ok = True)
      # Directory for keys:
      os.makedirs(os.path.join(self.Directory, 'private'),
                  mode     = 0o700,
                  exist_ok = True)

      # ====== Create index.txt, serial and crlnumber files =================
      if not os.path.isfile(self.IndexFileName):
         indexFile = open(self.IndexFileName, 'w', encoding='ascii')
         indexFile.close()

      if not os.path.isfile(self.SerialFileName):
         serialFile = open(self.SerialFileName, 'w', encoding='ascii')
         serialFile.write('00')
         serialFile.close()

      if not os.path.isfile(self.CRLNumberFileName):
         crlnumberFile = open(self.CRLNumberFileName, 'w', encoding='ascii')
         crlnumberFile.write('00')
         crlnumberFile.close()

      # ====== Generate CA configuration file ===============================
      if not os.path.isfile(self.ConfigFileName):
         configFile = open(os.path.join(self.ConfigFileName), 'w', encoding='utf-8')
         configFile.write("""
# ###########################################################################
# #### Test CA Configuration. See `man ca` for details!                  ####
# ###########################################################################

[ ca ]
default_ca                      = CA_default

[ CA_default ]
dir                             = """ + self.Directory          + """
certs                           = """ + self.CertsDirectory     + """
crl_dir                         = """ + self.CRLDirectory       + """
new_certs_dir                   = """ + self.NewCertsDirectory  + """
database                        = """ + self.IndexFileName      + """
serial                          = """ + self.SerialFileName     + """
RANDFILE                        = """ + self.Directory          + """

# The root key and root certificate.
private_key                     = """ + self.KeyFileName        + """
certificate                     = """ + self.CertFileName       + """

# For certificate revocation lists.
crlnumber                       = """ + self.CRLNumberFileName  + """
crl                             = """ + self.CRLFileName        + """
crl_extensions                  = crl_ext

default_days                    = """ + str(self.DefaultDays)   + """
default_crl_days                = 30
default_md                      = sha512

policy                          = policy_any
email_in_dn                     = yes

name_opt                        = ca_default   # Subject name display option
cert_opt                        = ca_default   # Certificate display option
copy_extensions                 = none         # Don't copy extensions from request

[ policy_any ]
countryName                     = optional
stateOrProvinceName             = optional
localityName                    = optional
organizationName                = optional
organizationalUnitName          = optional
streetAddress                   = optional
postalCode                      = optional
title                           = optional
surname                         = optional
givenName                       = optional
initials                        = optional
emailAddress                    = optional
commonName                      = supplied

# ====== PKCS#10 certificate request and certificate settings ===============
[ req ]
# See `man req` for details!
default_bits                    = 4096
default_md                      = sha512
distinguished_name              = req_distinguished_name
utf8                            = yes
string_mask                     = utf8only

# Extension to add when the -x509 option is used:
x509_extensions                 = v3_ca

[ req_distinguished_name ]
countryName                     = Country Name (2 letter code)
stateOrProvinceName             = State or Province Name
localityName                    = Locality Name
0.organizationName              = Organization Name
organizationalUnitName          = Organizational Unit Name
commonName                      = Common Name
emailAddress                    = Email Address

# Optionally, specify some defaults.
countryName_default             = NO
stateOrProvinceName_default     = Oslo
localityName_default            = Oslo
0.organizationName_default      =
organizationalUnitName_default  =
emailAddress_default            =

# ====== Extensions for CRLs ================================================
[ crl_ext ]
# See `man x509v3_config` for details!
authorityKeyIdentifier = keyid:always

# ====== Settings for root CA ===============================================
[ v3_ca ]
# See `man x509v3_config` for details!
subjectKeyIdentifier   = hash
basicConstraints       = critical, CA:true              # <<-- CA certificate
keyUsage               = critical, cRLSign, keyCertSign

# ====== Settings for intermediate CA =======================================
[ v3_intermediate_ca ]
# See `man x509v3_config` for details!
subjectKeyIdentifier   = hash
authorityKeyIdentifier = keyid:always,issuer
basicConstraints       = critical, CA:true              # <<-- CA certificate
keyUsage               = critical, cRLSign, keyCertSign

# ====== Settings for a leaf CA =============================================
[ v3_leaf_ca ]
# See `man x509v3_config` for details!
subjectKeyIdentifier   = hash
authorityKeyIdentifier = keyid:always,issuer
basicConstraints       = critical, CA:true, pathlen:0   # <<-- CA, but no sub-CAs
keyUsage               = critical, cRLSign, keyCertSign

# ====== Settings for a server certificate ==================================
[ server_cert ]
# Extensions for server certificates (`man x509v3_config`).
basicConstraints       = CA:FALSE
subjectKeyIdentifier   = hash
keyUsage               = critical, digitalSignature, keyEncipherment
extendedKeyUsage       = critical, serverAuth
subjectAltName         = ${ENV::SAN}

# ====== Settings for a server certificate ==================================
[ client_cert ]
# Extensions for server certificates (`man x509v3_config`).
basicConstraints       = CA:FALSE
subjectKeyIdentifier   = hash
keyUsage               = critical, digitalSignature, keyEncipherment
extendedKeyUsage       = critical, clientAuth
subjectAltName         = ${ENV::SAN}

# ====== Settings for a user certificate ====================================
[ user_cert ]
# Extensions for client certificates (`man x509v3_config`).
basicConstraints       = CA:FALSE
subjectKeyIdentifier   = hash
keyUsage               = critical, nonRepudiation, digitalSignature, keyEncipherment
extendedKeyUsage       = critical, clientAuth, emailProtection, codeSigning
subjectAltName         = ${ENV::SAN}
""")
         configFile.close()


      # ====== Generate CA password =========================================
      if not os.path.isfile(self.PasswordFileName):
         sys.stdout.write('\x1b[33mGenerating CA password ' + self.PasswordFileName + ' ...\x1b[0m\n')
         execute(f"""\
pwgen -sy 128 >{shlex.quote(self.PasswordFileName + '.tmp')} && \
mv {shlex.quote(self.PasswordFileName + '.tmp')} {shlex.quote(self.PasswordFileName)}""")
         assert os.path.isfile(self.PasswordFileName)


      # ====== Generate CA key ==============================================
      if not os.path.isfile(self.KeyFileName):
         sys.stdout.write('\x1b[33mGenerating CA key ' + self.KeyFileName + ' ...\x1b[0m\n')

         algorithmOptions : str
         if self.KeyAlgorithm == KeyAlgorithm.RSA:
            algorithmOptions = f'-algorithm RSA -pkeyopt rsa_keygen_bits:{self.KeyLength}'
         elif self.KeyAlgorithm == KeyAlgorithm.EC:
            algorithmOptions = f'-algorithm EC -pkeyopt ec_paramgen_curve:{self.ECCurve}'
         elif self.KeyAlgorithm == KeyAlgorithm.ED25519:
             algorithmOptions = "-algorithm ED25519"
         elif self.KeyAlgorithm == KeyAlgorithm.ED448:
             algorithmOptions = "-algorithm ED448"
         else:
            raise Exception('Unsupported key algorithm!')

         execute(f"""\
openssl genpkey \
   {algorithmOptions} \
   -aes256 \
   -out {shlex.quote(self.KeyFileName + '.tmp')} \
   -pass file:{shlex.quote(self.PasswordFileName)} && \
mv {shlex.quote(self.KeyFileName + '.tmp')} {shlex.quote(self.KeyFileName)}""")
         assert os.path.isfile(self.KeyFileName)

         # Make sure invalid files are removed:
         for fileName in [ self.CertFileName, self.CRLFileName ]:
            if os.path.exists(fileName):
               os.remove(fileName)


      # ====== Generate self-signed root CA certificate =====================
      self.RootCA : 'CA' | None = None
      if parentCA is None:
         # ------ Set reference to root CA (i.e. to itself) -----------------
         self.RootCA = self

         if not os.path.isfile(self.CertFileName):
            sys.stdout.write('\x1b[33mGenerating self-signed root CA certificate ' + self.CertFileName + ' ...\x1b[0m\n')
            execute(f"""\
SAN="" openssl req \
   -x509 \
   -config {shlex.quote(self.ConfigFileName)} \
   -extensions v3_ca \
   -utf8 -subj {shlex.quote(self.Subject)} \
   -days {str(self.DefaultDays)} \
   -key {shlex.quote(self.KeyFileName)} \
   -passin file:{shlex.quote(self.PasswordFileName)} \
   -out {shlex.quote(self.CertFileName + '.tmp')} && \
mv {shlex.quote(self.CertFileName + '.tmp')} {shlex.quote(self.CertFileName)}""")
            assert os.path.isfile(self.CertFileName)


      # ====== Generate CA certificate signed by parent CA ==================
      else:
         # ------ Set reference to root CA ----------------------------------
         if self.ParentCA is None:
            self.RootCA = self
         else:
            self.RootCA = self.ParentCA
            while ( (self.RootCA is not None) and
                    (self.RootCA.ParentCA is not None) ):
               self.RootCA = self.RootCA.ParentCA

         if not os.path.isfile(self.CertFileName):
            assert self.ParentCA is not None

            # ------ Generate CSR -------------------------------------------
            csrFileName : Final[str] = self.CertFileName + '.csr'
            sys.stdout.write('\x1b[33mGenerating CSR ' + csrFileName + ' ...\x1b[0m\n')
            execute(f"""\
SAN="" openssl req \
   -new \
   -config {shlex.quote(self.ConfigFileName)} \
   -extensions v3_ca \
   -utf8 -subj {shlex.quote(self.Subject)} \
   -key {shlex.quote(self.KeyFileName)} \
   -passin file:{shlex.quote(self.PasswordFileName)} \
   -out {shlex.quote(csrFileName + '.tmp')} && \
mv {shlex.quote(csrFileName + '.tmp')} {shlex.quote(csrFileName)}""")
            assert os.path.isfile(csrFileName)

            # ------ Get CSR signed by parent CA ----------------------------
            sys.stdout.write('\x1b[33mGetting CSR ' + csrFileName + ' signed by ' + parentCA.CAName + ' ...\x1b[0m\n')

            tmpCertFileName = self.CertFileName + '.tmp'
            execute(f"""\
SAN="" openssl ca \
   -batch \
   -notext \
   -config {shlex.quote(parentCA.ConfigFileName)} \
   -extensions {shlex.quote(self.Extension)} \
   -utf8 -subj {shlex.quote(self.Subject)} \
   -days {str(parentCA.DefaultDays)} \
   -passin file:{shlex.quote(parentCA.PasswordFileName)} \
   -in {shlex.quote(csrFileName)} \
   -out {shlex.quote(tmpCertFileName)}""")
            assert os.path.isfile(tmpCertFileName)

            # ------ Add the whole certificate chain ------------------------
            certFile  = open(tmpCertFileName, 'a', encoding='ascii')
            chainFile = open(self.ParentCA.CertFileName, 'r', encoding='ascii')
            for line in chainFile:
               certFile.write(line)
            os.rename(tmpCertFileName, self.CertFileName)

            # ------ Remove CSR ---------------------------------------------
            if os.path.exists(csrFileName):
               os.remove(csrFileName)


            # Check root CA -> ... -> certificate
            # NOTE: Using option "-untrusted" to mark the whole chain as untrusted
            #       works! The CAfile is always trusted, and OpenSSL will verify
            #       all certificates of the chain.
            #       -> https://stackoverflow.com/questions/25482199/verify-a-certificate-chain-using-openssl-verify
            command : str = f"""\
openssl verify \
 -show_chain \
 -verbose \
 -no-CApath \
 -no-CAstore \
 -CAfile {shlex.quote(self.RootCA.CertFileName)}"""
            if self.ParentCA:
               command += f' -untrusted {self.ParentCA.CertFileName}'
            command +=' ' + self.CertFileName
            execute(command)


      # ====== Generate initial CRL =========================================
      self.addToGlobalCRLDictionary(self)
      self.generateCRL()


      # ====== Print certificate ============================================
      if VerboseMode:
         execute(f"""\
openssl x509 \
 -noout \
 -subject -ext subjectAltName \
 -in {shlex.quote(self.CertFileName)}""")


   # ###### Sign certificate ################################################
   def signCertificate(self,
                       certificate : 'Certificate',
                       csrFileName : str) -> None:

      # ------ Sign CSR -----------------------------------------------------
      sys.stdout.write('\x1b[33mGetting CSR ' + csrFileName + ' signed by ' + self.CAName + ' ...\x1b[0m\n')
      assert os.path.isfile(csrFileName)

      tmpCertFileName = certificate.CertFileName + '.tmp'
      execute(f"""\
SAN={shlex.quote(certificate.SubjectAltName)} openssl ca \
 -batch \
 -notext \
 -config {shlex.quote(self.ConfigFileName)} \
 -extensions {shlex.quote(certificate.Extension)} \
 -utf8 -subj {shlex.quote(certificate.Subject)} \
 -days {shlex.quote(str(self.DefaultDays))} \
 -passin file:{shlex.quote(self.PasswordFileName)} \
 -in {shlex.quote(csrFileName)} \
 -out {shlex.quote(tmpCertFileName)}""")
      assert os.path.isfile(tmpCertFileName)

      # ------ Add the whole chain ------------------------------------------
      certFile  = open(tmpCertFileName, 'a', encoding='ascii')
      chainFile = open(self.CertFileName, 'r', encoding='ascii')
      for line in chainFile:
         certFile.write(line)
      os.rename(tmpCertFileName, certificate.CertFileName)


   # ###### Verify certificate ##############################################
   def verifyCertificate(self, certificate : 'Certificate') -> bool:
      sys.stdout.write('\x1b[33mVerifying certificate ' + certificate.CertFileName + ' ...\x1b[0m\n')
      assert self.RootCA is not None
      result = execute(f"""\
openssl verify \
 -show_chain \
 -verbose \
 -crl_check \
 -no-CApath \
 -no-CAstore \
 -CAfile {shlex.quote(self.RootCA.CertFileName)} \
 -untrusted {shlex.quote(self.CertFileName)} \
 -CRLfile {shlex.quote(self.CRLFileName)} \
 {shlex.quote(certificate.CertFileName)}""", mayFail=True)
      return result == 0


   # ###### Revoke CA #######################################################
   def revokeCA(self, ca : 'CA') -> None:
      sys.stdout.write('\x1b[33mRevoking CA ' + ca.CertFileName + ' ...\x1b[0m\n')
      assert os.path.isfile(ca.CertFileName)
      result = execute(f"""\
openssl ca \
 -revoke {shlex.quote(ca.CertFileName)} \
 -config {shlex.quote(self.ConfigFileName)} \
 -passin file:{shlex.quote(self.PasswordFileName)}""")
      self.generateCRL()

      # Remove the now-invalid certificate file:
      if os.path.exists(ca.CertFileName):
         os.remove(ca.CertFileName)


   # ###### Revoke certificate ##############################################
   def revokeCertificate(self, certificate : 'Certificate') -> None:
      sys.stdout.write('\x1b[33mRevoking certificate ' + certificate.CertFileName + ' ...\x1b[0m\n')
      assert os.path.isfile(self.CertFileName)
      result = execute(f"""\
SAN="" openssl ca \
 -revoke {shlex.quote(certificate.CertFileName)} \
 -config {shlex.quote(certificate.CA.ConfigFileName)} \
 -passin file:{shlex.quote(certificate.CA.PasswordFileName)}""")
      self.generateCRL()

      # Remove the now-invalid certificate file:
      if os.path.exists(certificate.CertFileName):
         os.remove(certificate.CertFileName)


   # ###### Generate CRL ####################################################
   def generateCRL(self) -> None:
      sys.stdout.write('\x1b[33mGenerating CRL ' + self.CRLFileName + ' ...\x1b[0m\n')
      execute(f"""\
SAN="" openssl ca \
 -gencrl \
 -config {shlex.quote(self.ConfigFileName)} \
 -passin file:{shlex.quote(self.PasswordFileName)} \
 -out {shlex.quote(self.CRLFileName + '.tmp')} && \
mv {shlex.quote(self.CRLFileName + '.tmp')} {shlex.quote(self.CRLFileName)}""")
      assert(os.path.isfile(self.CRLFileName))

      # ====== Update global CRL ============================================
      self.generateGlobalCRL(self.GlobalCRLFileName)


   # ###### Add CA to global CRL dictionary #################################
   def addToGlobalCRLDictionary(self, ca : 'CA') -> None:
      level     : int  = 1
      currentCA : 'CA' = ca
      while currentCA.ParentCA is not None:
         level = level + 1
         currentCA = currentCA.ParentCA
      GlobalCRLDictionary[ca.CRLFileName] = level


   # ###### Generate global CRL #############################################
   def generateGlobalCRL(self, globalCRLFileName : str) -> None:
      if len(GlobalCRLDictionary) > 0:
         cmd = 'cat'
         for crlFileName, level in sorted(GlobalCRLDictionary.items(),
                                          key = lambda item : ( item[1], item[0] ) ):
            cmd = cmd + ' ' + crlFileName
         cmd = cmd + ' >' + globalCRLFileName
         execute(cmd)
         assert(os.path.isfile(globalCRLFileName))


# ###### Server/Client/User Certificate #####################################
class Certificate:

   # ###### Constructor #####################################################
   def __init__(self,
                mainDirectory    : str,
                name             : str,
                ca               : CA,
                subjectWithoutCN : str,
                subjectAltName   : str,
                certType         : CertificateType = CertificateType.Server,
                keyAlgorithm     : KeyAlgorithm    = DefaultKeyAlgorithm,
                keyLength        : int             = DefaultCertKeyLength,
                ecCurve          : str             = DefaultECCurve,
                revokeIfExisting : bool            = False):

      sys.stdout.write('\x1b[34mCreating certificate ' + name + ' ...\x1b[0m\n')

      safeName            : Final[str] = re.sub(r'[^a-zA-Z0-9+-\.]', '_', name)
      self.CA             : CA                     = ca
      self.Directory      : Final[str]             = os.path.join(os.path.abspath(mainDirectory), safeName)
      self.Subject        : Final[str]             = subjectWithoutCN + '/CN=' + name
      self.SubjectAltName : Final[str]             = subjectAltName
      self.CertType       : Final[CertificateType] = certType
      self.Extension      : str
      if self.CertType == CertificateType.Server:
         self.Extension = 'server_cert'
      elif self.CertType == CertificateType.Client:
         self.Extension = 'client_cert'
      elif self.CertType == CertificateType.User:
         self.Extension = 'user_cert'
      else:
         raise Exception('Invalid certificate type')


      self.KeyAlgorithm : Final[KeyAlgorithm] = keyAlgorithm
      self.KeyLength    : Final[int] = keyLength
      self.ECCurve      : Final[str] = ecCurve
      self.KeyFileName  : Final[str] = os.path.join(self.Directory, safeName + '.key')
      self.CertFileName : Final[str] = os.path.join(self.Directory, safeName + '.crt')

      os.makedirs(self.Directory, exist_ok = True)

      # ====== Revoke existing certificate first ============================
      if revokeIfExisting:
         if os.path.isfile(self.CertFileName):
            self.revoke()

      # ====== Generate key =================================================
      if not os.path.isfile(self.KeyFileName):
         sys.stdout.write('\x1b[33mGenerating key ' + self.KeyFileName + ' ...\x1b[0m\n')

         keyFileName      : str = os.path.join(self.Directory, self.KeyFileName)
         algorithmOptions : str
         if self.KeyAlgorithm == KeyAlgorithm.RSA:
            algorithmOptions = f'-algorithm RSA -pkeyopt rsa_keygen_bits:{self.KeyLength}'
         elif self.KeyAlgorithm == KeyAlgorithm.EC:
            algorithmOptions = f'-algorithm EC -pkeyopt ec_paramgen_curve:{self.ECCurve}'
         elif self.KeyAlgorithm == KeyAlgorithm.ED25519:
             algorithmOptions = "-algorithm ED25519"
         elif self.KeyAlgorithm == KeyAlgorithm.ED448:
             algorithmOptions = "-algorithm ED448"
         else:
            raise Exception('Unsupported key algorithm!')

         execute(f"""\
openssl genpkey \
 {algorithmOptions} \
 -out {shlex.quote(keyFileName + '.tmp')} && \
mv {shlex.quote(keyFileName + '.tmp')} {shlex.quote(keyFileName)}""")
         assert os.path.isfile(keyFileName)

         #  Make sure that an invalid cerfificate file is removed:
         if os.path.exists(self.CertFileName):
            os.remove(self.CertFileName)

      # ====== Generate certificate signed by CA ============================
      if not os.path.isfile(self.CertFileName):
         # ------ Generate CSR ----------------------------------------------
         csrFileName : Final[str] = os.path.join(self.Directory, safeName + '.csr')
         sys.stdout.write('\x1b[33mGenerating CSR ' + csrFileName + ' ...\x1b[0m\n')
         execute(f"""\
SAN={shlex.quote(self.SubjectAltName)} openssl req \
 -new \
 -config {shlex.quote(self.CA.ConfigFileName)} \
 -extensions {shlex.quote(self.Extension)} \
 -utf8 -subj {shlex.quote(self.Subject)} \
 -key {shlex.quote(self.KeyFileName)} \
 -out {shlex.quote(csrFileName + '.tmp')} && \
mv {shlex.quote(csrFileName + '.tmp')} {shlex.quote(csrFileName)}""")
         assert os.path.isfile(csrFileName)

         # ------ Get CSR signed by CA --------------------------------------
         assert os.path.isfile(csrFileName)
         self.CA.signCertificate(self, csrFileName)
         assert os.path.isfile(self.CertFileName)

         # ------ Remove CSR ------------------------------------------------
         if os.path.exists(csrFileName):
            os.remove(csrFileName)

         assert self.verify()

      # ====== Verify certificate ===========================================
      # Print certificate:
      execute(f"""\
openssl x509 \
 -noout \
 -subject -ext subjectAltName \
 -in {shlex.quote(self.CertFileName)}""")


   # ###### Revoke certificate ##############################################
   def revoke(self) -> None:
      sys.stdout.write('\x1b[34mRevoking certificate ' + self.CertFileName + ' ...\x1b[0m\n')

      assert os.path.isfile(self.CertFileName)
      self.CA.revokeCertificate(self)
      if self.verify() == False:
         sys.stdout.write('Successfully revoked!\n')


   # ###### Verify certificate ##############################################
   def verify(self) -> bool:

      return self.CA.verifyCertificate(self)
