Files
2026-06-23 11:27:54 +08:00

689 lines
25 KiB
Python

# Copyright (c) 2024-2025 Broadcom. All Rights Reserved.
# The term "Broadcom" refers to Broadcom Inc.
# and/or its subsidiaries.
import base64
import subprocess
import OpenSSL
import re
from datetime import datetime
from lib.exceptions import CommandExecutionError
from lib.command_runner import CommandRunner
from lib.console import print_text_error
from lib.input import MenuInput
from lib.host_utils import get_file_contents, get_file_type
from lib.text_utils import TextFilter
OPENSSL_CLI = '/usr/bin/openssl'
CERTOOL_CLI = '/usr/lib/vmware-vmca/bin/certool'
def get_command_output_with_input(args, input_text):
"""
Get command output with the specified input
:param args: Command and its arguments
:param input_text: input text to be supplied to stdin
:return: command output from stdout
"""
cmd = CommandRunner(*args, command_input=input_text, expected_return_code=0)
_, stdout, _ = cmd.run()
return stdout
def get_certificate_info(pem_text):
"""
Get certificate information from openssl command
This is a wrapper for 'openssl x509 -text -fingerprint -noout' command
:param pem_text: Certificate in PEM format
:return: Certificate info generated by openssl command
"""
args = [OPENSSL_CLI, 'x509', '-noout', '-text', '-fingerprint']
return get_command_output_with_input(args, pem_text).strip()
def get_subject_hash(pem_cert):
"""
Get the Subject hash for a certificate
This is a wrapper for the 'openssl x509 -noout -subject_hash' command
:param pem_cert: Base64 certificate hash
:return: Subject hash returned by openssl
"""
args = [OPENSSL_CLI, 'x509', '-noout', '-subject_hash']
return get_command_output_with_input(args, pem_cert).strip()
def get_formatted_dn(x509_name, use_slash_sep=False):
"""
Get formatted distinguished name for subject and issuer
The original vCert depends on 'openssl x509 -subject' format, but the output format
varies between openssl versions.
- openssl 1.0 (VC 6.x):
subject= /CN=CA/DC=vsphere/DC=local/C=US/ST=California/O=sc2-10-184-104-251.eng.vmware.com/OU=VMware Engineering
- openssl 3.0 (VC 7.x, 8.x):
subject=CN = CA, DC = vsphere, DC = local, C = US, ST = California, O = sc2-10-184-104-251.eng.vmware.com, OU = VMware Engineering
- openssl 3.2:
subject=CN=CA, DC=vsphere, DC=local, C=US, ST=California, O=sc2-10-184-104-251.eng.vmware.com, OU=VMware Engineering
Unless requested, we will use the last one since it's the most widely used format.
:param x509_name: OpenSSL.crypto.X509Name object
:param use_slash_sep: Use slash separator
:return: formatted distinguished name
"""
comps = []
comp_map = dict()
for comp in x509_name.get_components():
comp_name = str(comp[0], 'utf-8')
comp_value = str(comp[1], 'utf-8')
comp_map[comp_name] = comp_value
comps.append("{}={}".format(comp_name, comp_value))
if use_slash_sep:
return "/{}".format('/'.join(comps))
else:
return ', '.join(comps)
def get_subject_and_issuer_dn(x509_cert):
"""
Get Subject DN and Issuer DN from X509 certificate in a formatted DN
:param x509_cert: X509Certificate object
:return: tuple (formatted subject DN, formatted issuer DN)
"""
return get_formatted_dn(x509_cert.get_subject()), get_formatted_dn(x509_cert.get_issuer())
def get_certificate_extensions(x509_cert):
"""
Get X509 certificate extensions as a dict object
:param x509_cert: X509 certificate
:return: X509 certificate extensions
"""
extensions = dict()
for idx in range(x509_cert.get_extension_count()):
ext = x509_cert.get_extension(idx)
ext_short_name = ext.get_short_name().decode('utf-8')
try:
extensions[ext_short_name] = str(ext)
except OpenSSL.crypto.Error:
pass
return extensions
def get_subject_keyid(x509_cert, remove_colons=False, allow_alternate=False):
"""
Get Subject Key Identifier from X509v3 extension
If the keyid is not available and {allow_alternate} is True, the keyid
will be generated using serial/subject_dn pair.
:param x509_cert: X509 certificate
:param remove_colons: Remove colon separators
:param allow_alternate: Allow alternate keyid value using serial/subject_dn
:return: subject keyid
"""
extensions = get_certificate_extensions(x509_cert)
skid = extensions.get('subjectKeyIdentifier')
if skid:
if remove_colons:
skid = skid.replace(':', '')
elif allow_alternate:
serial = get_serial_number(x509_cert)
subject_dn = get_formatted_dn(x509_cert.get_subject(), use_slash_sep=True)
skid = "DirName:{},serial:{}".format(subject_dn, serial)
else:
skid = ''
return skid
def get_authority_keyid(x509_cert, remove_colons=False, allow_alternate=False):
"""
Get Authority Key Identifier from X509v3 extension
If the keyid is not available and {allow_alternate} is True, the keyid
will be generated using serial/DirName pair, if available
:param x509_cert: X509 certificate
:param remove_colons: Remove colon separators
:param allow_alternate: Allow alternate keyid value using serial/subject_dn
:return: authority keyid
"""
akid = ''
extensions = get_certificate_extensions(x509_cert)
akids = extensions.get('authorityKeyIdentifier')
if akids:
akid = TextFilter(akids).start_with('keyid:').get_text()
if not akid and len(akids.splitlines()) == 1 and len(akids.split(':')) >= 7:
akid = akids
if akid:
akid = akid.replace('keyid:', '')
if remove_colons:
akid = akid.replace(':', '')
elif akids and allow_alternate:
dir_name = TextFilter(akids).start_with('DirName:').get_text()
serial = TextFilter(akids).start_with('serial:').get_text()
if dir_name and serial:
akid = "{},{}".format(dir_name, serial)
return akid
def get_serial_number(x509_cert):
"""
Get certificate serial number in colon separated capitalized text,
e.g. 88:EC:FC:3B:FB:3F:53:52
:param x509_cert: X509 certificate
:return: certificate serial number
"""
serial = "{:X}".format(x509_cert.get_serial_number())
if len(serial) % 2 != 0:
serial = "0{}".format(serial)
parts = [serial[idx:idx+2] for idx in range(0, len(serial), 2)]
return ":".join(parts)
def get_certificate_start_date(x509_cert):
"""
Get certificate start date
:param x509_cert: X509 certificate object
:return: Certificate's start date as a datetime object
"""
input_format = '%Y%m%d%H%M%SZ'
return datetime.strptime(x509_cert.get_notBefore().decode('utf-8'), input_format)
def get_certificate_end_date(x509_cert):
"""
Get certificate end date
:param x509_cert: X509 certificate object
:return: Certificate's end date as a datetime object
"""
input_format = '%Y%m%d%H%M%SZ'
return datetime.strptime(x509_cert.get_notAfter().decode('utf-8'), input_format)
def get_certificate_fingerprint(x509_cert, algorithm=None, remove_colons=False):
"""
Get certificate fingerprint
:param x509_cert: X509 certificate object
:param algorithm: fingerprint algorithm. If it's not specified, use sha1
:param remove_colons: remove colon delimiters
:return:
"""
if algorithm is None:
algorithm = 'sha1'
fingerprint = x509_cert.digest(algorithm).decode('utf-8')
return fingerprint.replace(':', '') if remove_colons else fingerprint
def get_x509_certificate(pem_cert):
"""
Load X509Certificate from PEM text
:param pem_cert: Certificate in PEM format
:return: X509Certificate object
"""
return OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, pem_cert)
def get_certificate_expiry_in_days(x509_cert):
"""
Return certificate expiry in days
:param x509_cert: X509 certificate object
:return: certificate's expiry in days
"""
return (get_certificate_end_date(x509_cert) - datetime.utcnow()).days
def is_x509_expired(x509_cert):
"""
Checks if x509 object is expired
"""
days_left = get_certificate_expiry_in_days(x509_cert)
return True if days_left < 0 else False
def is_cert_expired(pem_cert):
"""
Checks if Base64 certificate is expired
"""
x509_cert = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, pem_cert)
return is_x509_expired(x509_cert)
def is_cert_file_expired(cert_file):
"""
Checks if PEM certificate file is expired
"""
pem_cert = get_file_contents(cert_file)
return is_cert_expired(pem_cert)
def get_subject_alternative_names(x509_cert):
"""
Get SAN entries in the certificate
:param x509_cert: X590 certificate object
:return: list of SAN entries (including the prefixes)
"""
extensions = get_certificate_extensions(x509_cert)
return extensions.get('subjectAltName')
def get_cert_info_for_path_building(pem_cert):
"""
Obtain the basic certificate information for certificate path
building. The information includes:
- subject keyId
- authority keyId
- certificate name (common name)
- whether the certificate is self-signed
- whether the certificate is trusted
- whether the certificate is a CA
- pathlen of the certificate basic constraints
(or False if basic constraints are not present)
:param pem_cert: Certificate in PEM format
:return: dict object contains the required information
"""
x509_cert = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, pem_cert)
subject_dn, issuer_dn = get_subject_and_issuer_dn(x509_cert)
skid = get_subject_keyid(x509_cert, remove_colons=True, allow_alternate=True)
akid = get_authority_keyid(x509_cert, remove_colons=True, allow_alternate=True)
basic_constraints = get_certificate_extensions(x509_cert).get('basicConstraints')
if basic_constraints is not None:
pathlen_search = re.search('pathlen:[0-9]+', basic_constraints)
pathlen = pathlen_search.group().split(':')[1] if pathlen_search is not None else False
else:
pathlen = False
if skid and akid:
is_selfsigned = skid == akid
else:
is_selfsigned = subject_dn == issuer_dn
cert_name = get_certificate_name(pem_cert)
return {
"subject_keyid": skid,
"auth_keyid": akid,
"cert_name": cert_name,
"is_selfsigned": is_selfsigned,
"is_trusted": False,
"is_ca": is_ca_certificate(x509_cert),
"pathlen": pathlen,
}
def get_certificate_fetcher_from_list(pem_certs):
"""
Method to compose certificate fetcher closure method from certificate list
in PEM text. The fetcher method will be used by build_certification_path
method.
:param pem_certs: List of certificate in PEM format
:return: subject keyId list, fetcher method
"""
subject_keyids = []
cert_map = dict()
for pem_cert in pem_certs:
cert = get_x509_certificate(pem_cert)
subject_keyid = get_subject_keyid(cert, remove_colons=True, allow_alternate=True)
if subject_keyid:
cert_map[subject_keyid] = pem_cert
subject_keyids.append(subject_keyid)
def fetch_certificate_closure(skid):
return cert_map.get(skid)
return subject_keyids, fetch_certificate_closure
def build_certification_path(pem_cert, trusted_subject_keyids, certificate_fetcher):
"""
Build certification path using a strict method of subject keyId and authority keyId
matching.
:param pem_cert: Certificate in PEM format. It may contain intermediate CA certificate
:param trusted_subject_keyids: list of subject keyId of trusted root CA certificates
:param certificate_fetcher: method to fetch certificate from subject keyId
:return: list of certificate info, from leaf certificate to root CA certificate
"""
cert_map = dict()
machine_certs = split_certificates_from_pem(pem_cert)
cur_cert = None
for idx, machine_cert in enumerate(machine_certs):
cert_info = get_cert_info_for_path_building(machine_cert)
subject_keyid = cert_info['subject_keyid']
cert_info['is_trusted'] = subject_keyid in trusted_subject_keyids
if idx == 0:
cur_cert = cert_info
else:
cert_map[subject_keyid] = cert_info
cert_path = [cur_cert]
while not cur_cert['is_selfsigned'] and cur_cert['auth_keyid'] is not None:
auth_keyid = cur_cert['auth_keyid']
cert_info = cert_map.get(auth_keyid)
if not cert_info:
next_pem_cert = certificate_fetcher(auth_keyid)
if not next_pem_cert:
break
cert_info = get_cert_info_for_path_building(next_pem_cert)
if cert_info['subject_keyid'] != auth_keyid:
break
cert_info['is_trusted'] = True
cert_map[cert_info["subject_keyid"]] = cert_info
cert_path.insert(0, cert_info)
cur_cert = cert_info
return cert_path
def split_certificates_from_pem(pem):
"""
Get list of individual certificates from PEM text
:param pem: Certificates in PEM format
:return: list of certificates in PEM format
"""
return TextFilter(pem).match_block('-----BEGIN CERTIFICATE-----',
'-----END CERTIFICATE-----', concatenate=True).get_lines()
def get_certificate_from_host(hostname, port=443):
"""
Get server TLS certificate from host via SSL handshake
:param hostname: hostname to connect
:param port: port
:return: Server certificate in PEM format
"""
remote_host = "{}:{}".format(hostname, port)
output = CommandRunner(OPENSSL_CLI, 's_client', '-connect', remote_host, '-showcerts',
command_input=subprocess.DEVNULL).run_and_get_output()
pem_cert = TextFilter(output).match_block('^-----BEGIN CERTIFICATE-----$',
'^-----END CERTIFICATE-----$',
concatenate=True).get_text()
return pem_cert
def build_pem_certificate(cert_text):
"""
Build PEM certificate based on certificate text input
This is required since certificates are often stored in DB
using a single line text.
:param cert_text: certificate text to be processed
:return: return certificate in a proper PEM format
"""
if type(cert_text) is bytes:
if cert_text.startswith(b'-----BEGIN CERTIFICATE-----'):
pem_cert = cert_text.replace(b'\\n', b'\n').decode('utf-8').strip()
elif cert_text.startswith(b'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t'):
pem_cert = base64.decodebytes(cert_text).decode('utf-8').strip()
else:
pem_cert = '\n'.join(['-----BEGIN CERTIFICATE-----',
base64.encodebytes(cert_text).decode('utf-8').strip(),
'-----END CERTIFICATE-----'])
else:
if cert_text.startswith('-----BEGIN CERTIFICATE-----'):
pem_cert = cert_text.replace('\\n', '\n')
else:
pem_cert = '\n'.join(['-----BEGIN CERTIFICATE-----', cert_text.strip(),
'-----END CERTIFICATE-----'])
# reformat to ensure that each line less than or equal to 72 characters
lines = pem_cert.splitlines()
contents = ''.join(lines[1:-1])
result = [lines[0]]
for line in contents.splitlines():
while len(line) > 72:
result.append(line[:72])
line = line[72:]
if line:
result.append(line)
result.append(lines[-1])
return '\n'.join(result)
def is_ca_certificate(x509_cert):
"""
Check if certificate is a CA certificate
:param x509_cert: certificate
:return: True of a CA certificate, otherwise False
"""
basic_constraints = get_certificate_extensions(x509_cert).get('basicConstraints')
key_usage = get_certificate_extensions(x509_cert).get('keyUsage')
is_ca = basic_constraints is not None and 'CA:TRUE' in basic_constraints
can_sign_certs = key_usage is not None and 'Certificate Sign' in key_usage
return is_ca and can_sign_certs
def is_self_signed_certificate(x509_cert):
"""
Check if the certificate is a self-signed certificate
:param x509_cert: X509 certificate
:return: True if self-signed, otherwise False
"""
subject_kid = get_subject_keyid(x509_cert, allow_alternate=True)
auth_keyid = get_authority_keyid(x509_cert, allow_alternate=True)
if subject_kid and auth_keyid:
return subject_kid == auth_keyid
else:
subject_dn, issuer_dn = get_subject_and_issuer_dn(x509_cert)
return subject_dn == issuer_dn
def generate_vmca_signed_certificate(config, output_dir, filename):
"""
Generate VMCA signed certificate using certool utility
In the {output_dir} directory, the following will be created:
- {filename}.key : the private key
- {filename}.pub : the public key
- {filename}.crt : the certificate file
:param config: certool config file
:param output_dir: The output directory
:param filename: The filename for the output (without extension)
"""
privkey_arg = "--privkey={}/{}.key".format(output_dir, filename)
pubkey_arg = "--pubkey={}/{}.pub".format(output_dir, filename)
CommandRunner(CERTOOL_CLI, '--genkey', privkey_arg, pubkey_arg, expected_return_code=0).run()
config_arg = "--config={}".format(config)
cert_arg = "--cert={}/{}.crt".format(output_dir, filename)
CommandRunner(CERTOOL_CLI, privkey_arg, '--gencert', cert_arg, config_arg, expected_return_code=0).run()
def detect_and_convert_to_pem(input_cert, password=None, allow_input=False):
"""
Detect certificate file format and convert it to PEM certificate as necessary
:param input_cert: The certificate file
:param password: In case of PKCS#12, the store password
:param allow_input: In case of PKCS#12, allow the method to ask for password
:return: tuple of ( certificate in PEM, private key in PEM of None )
"""
file_type = get_file_type(input_cert)
if 'PEM' in file_type or 'ASCII' in file_type:
contents = get_file_contents(input_cert)
cert_pem, key_pem = get_certificate_and_private_key(contents)
if cert_pem or key_pem:
return cert_pem, key_pem
# need conversion, try PKCS#12 format
if is_pkcs12_file(input_cert):
result = convert_pkcs12_to_pem(input_cert)
if password is not None:
result = convert_pkcs12_to_pem(input_cert, password)
if result is None:
result = convert_pkcs12_to_pem(input_cert, 'Antidisestablishmentarianism123581321',
allow_input=allow_input)
if result is None:
raise CommandExecutionError('Unable to validate keystore password')
return result
# DER format
cert_pem = CommandRunner(OPENSSL_CLI, 'x509', '-inform', 'der', '-in', input_cert,
'-outform', 'pem').run_and_get_output().strip()
key_pem = CommandRunner(OPENSSL_CLI, 'rsa', '-inform', 'der', '-in', input_cert,
'-outform', 'pem').run_and_get_output().strip()
output = "{}\n{}".format(cert_pem, key_pem) if cert_pem or key_pem else ''
if not output:
# PKCS#7, PEM
output = CommandRunner(OPENSSL_CLI, 'pkcs7', '-print_certs', '-inform', 'pem', '-in',
input_cert, '-outform', 'pem').run_and_get_output().strip()
if not output:
# PKCS#7, DER
output = CommandRunner(OPENSSL_CLI, 'pkcs7', '-print_certs', '-inform', 'der', '-in',
input_cert, '-outform', 'pem').run_and_get_output().strip()
if output:
return get_certificate_and_private_key(output)
else:
raise CommandExecutionError('Unsupported file format')
def is_pkcs12_file(filename):
ret_code, _, stderr = CommandRunner(OPENSSL_CLI, 'pkcs12', '-noout', '-in', filename,
'-passin', 'pass:').run()
if ret_code != 0 and 'asn1 error' in stderr:
return False
else:
return True
def convert_pkcs12_to_pem(cert_file, password=None, allow_input=False):
"""
Convert PKCS#12 format to PEM
:param cert_file:
:param password:
:param allow_input:
:return:
"""
password_arg = "pass:{}".format(password if password is not None else '')
ret_code, stdout, stderr = CommandRunner(OPENSSL_CLI, 'pkcs12', '-nokeys', '-info',
'-in', cert_file, '-passin', password_arg, '-nomacver').run()
if ret_code != 0 and allow_input and 'pkcs12 cipherfinal error' in stderr:
retry = 0
password = ''
while retry < 3 and not password:
password = MenuInput('Enter password for PKCS#12 keystore: ',
case_insensitive=False, masked=True).get_input()
password_arg = "pass:{}".format(password)
ret_code, stdout, _ = CommandRunner(OPENSSL_CLI, 'pkcs12', '-nokeys', '-info',
'-in', cert_file, '-passin', password_arg, '-nomacver').run()
if ret_code != 0:
print_text_error('Invalid password.')
password = ''
retry += 1
if ret_code == 0:
cert_pem, _ = get_certificate_and_private_key(stdout)
ret_code, stdout, _ = CommandRunner(OPENSSL_CLI, 'pkcs12', '-nocerts', '-nodes',
'-info', '-in', cert_file, '-passin', password_arg, '-nomacver').run()
_, key_pem = get_certificate_and_private_key(stdout)
return cert_pem, key_pem
return None
def get_certificate_and_private_key(text):
"""
Separate certificate and private key parts from PEM text
:param text: PEM text contains certificate and private key
"""
cert_pem = TextFilter(text)\
.match_block('^-----BEGIN CERTIFICATE-----',
'^-----END CERTIFICATE-----').get_text()
key_pem = TextFilter(text) \
.match_block('^-----BEGIN .*PRIVATE KEY-----',
'^-----END .*PRIVATE KEY-----') \
.get_text()
return cert_pem, key_pem
def get_key_modulus_from_pem_text(cert_or_key_text):
"""
Get the key modulus from certificate or private key text (PEM)
:param cert_or_key_text: certificate or key file
:return: The key modulus
"""
if TextFilter(cert_or_key_text).match('^-----BEGIN CERTIFICATE-----').get_lines():
modulus = CommandRunner(OPENSSL_CLI, 'x509', '-noout', '-modulus',
command_input=cert_or_key_text).run_and_get_output()
elif TextFilter(cert_or_key_text).match('^-----BEGIN .*PRIVATE KEY-----').get_text():
modulus = CommandRunner(OPENSSL_CLI, 'rsa', '-noout', '-modulus',
command_input=cert_or_key_text).run_and_get_output()
else:
return None
return TextFilter(modulus).cut('=', [1]).get_text().strip()
def generate_csr(openssl_config, csr_file, key_file):
"""
Generate CSR file using config {openssl_config}
:param openssl_config: OpenSSL config file
:param csr_file: The output CSR file
:param key_file: The output key file
"""
command = CommandRunner(OPENSSL_CLI, 'req', '-new', '-newkey', 'rsa:3072', '-nodes', '-config',
openssl_config, '-out', csr_file, '-keyout', key_file, expected_return_code=0)
command.run()
def load_pem_certificate_file_in_der(cert_file, leaf_only=False):
"""
Load PEM certificates and return them in DER format
"""
certs_pem = split_certificates_from_pem(get_file_contents(cert_file))
certs_der = []
for cert_pem in certs_pem:
cert_x509 = get_x509_certificate(cert_pem)
certs_der.append(OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_ASN1, cert_x509))
if leaf_only:
return certs_der[:1]
return certs_der
def load_pem_key_file_in_pkcs8_der(key_file):
"""
Load PEM key file and return them in PKCS#8 DER format
"""
key_der = CommandRunner(OPENSSL_CLI, 'pkcs8', '-topk8', '-inform', 'pem', '-outform',
'der', '-in', key_file, '-out', '/dev/stdout', '-nocrypt',
command_input=subprocess.DEVNULL, binary_output=True).run_and_get_output()
return key_der
def get_certificate_name(pem_cert, target_dn='subject') -> str:
"""
Get the name of the entity in an x509 certificate, taken from the
Subject attributes CommonName, OrgUnit, and Organization (in that order of preference)
:param pem_cert: Base64 hash of a certificate
:return: string of the computed certificate name
"""
x509_cert = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, pem_cert)
# get certificate name, typically will return the common name
comps = dict()
dn = x509_cert.get_subject() if target_dn == 'subject' else x509_cert.get_issuer()
for comp in dn.get_components():
comp_name = str(comp[0], 'utf-8')
comp_value = str(comp[1], 'utf-8')
comps[comp_name] = comp_value
cert_name = '<unknown>'
for key in ['CN', 'OU', 'O']:
if comps.get(key):
cert_name = comps[key]
break
return cert_name