././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1743149646.0600467 certipy-0.2.2/0000755000175100001660000000000014771455116012610 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1743149646.0550466 certipy-0.2.2/.github/0000755000175100001660000000000014771455116014150 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1743149646.0570467 certipy-0.2.2/.github/workflows/0000755000175100001660000000000014771455116016205 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1743149635.0 certipy-0.2.2/.github/workflows/ci.yml0000644000175100001660000000150314771455103017316 0ustar00runnerdockername: ci on: push: branches: - main pull_request: branches: - main concurrency: group: ci-${{github.ref}}-${{github.event.pull_request.number || github.run_number}} cancel-in-progress: true jobs: test: runs-on: ${{ matrix.os || 'ubuntu-latest' }} strategy: fail-fast: false matrix: python: - "3.8" - "3.12" - "3.x" include: - os: ubuntu-22.04 python: "3.7" steps: - uses: actions/checkout@v4 - name: setup uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} - name: install run: | python -m pip install --upgrade pip pip install -e . - name: test run: | pip install ."[dev]" pytest ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1743149635.0 certipy-0.2.2/.github/workflows/release.yml0000644000175100001660000000167414771455103020354 0ustar00runnerdockername: release on: push: paths: - "certipy/version.py" branches: - main jobs: release-build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.x" - name: build release distributions run: | python -m pip install build python -m build - name: upload dists uses: actions/upload-artifact@v4 with: name: release-dists path: dist/ pypi-publish: runs-on: ubuntu-latest environment: name: Release needs: - release-build permissions: id-token: write steps: - name: Retrieve release distributions uses: actions/download-artifact@v4 with: name: release-dists path: dist/ - name: Publish release distributions to PyPI uses: pypa/gh-action-pypi-publish@release/v1 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1743149635.0 certipy-0.2.2/.gitignore0000644000175100001660000000256614771455103014605 0ustar00runnerdocker# asdf .tool-versions # vim *.sw* # direnv .envrc # macOS .DS_Store .AppleDouble .LSOverride # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # dotenv .env # virtualenv .venv venv/ venvs/ ENV/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ # IDE settings .vscode/ .idea/ # mkdocs build dir site/ # node node_modules # Ignore dynaconf secret files .secrets.* ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1743149635.0 certipy-0.2.2/.pre-commit-config.yaml0000644000175100001660000000060014771455103017061 0ustar00runnerdockerrepos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 hooks: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.6.2 hooks: - id: ruff types: - python args: ["--fix", "--show-fixes"] - id: ruff-format types: - python ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1743149635.0 certipy-0.2.2/LICENSE0000644000175100001660000000300514771455103013607 0ustar00runnerdockerBSD 3-Clause License Copyright (c) 2018, Lawrence Livermore National Security, LLC All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1743149635.0 certipy-0.2.2/MANIFEST.in0000644000175100001660000000003714771455103014342 0ustar00runnerdockerinclude LICENSE include NOTICE ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1743149635.0 certipy-0.2.2/NOTICE0000644000175100001660000000221714771455103013512 0ustar00runnerdockerThis work was produced under the auspices of the U.S. Department of Energy by Lawrence Livermore National Laboratory under Contract DE-AC52-07NA27344. This work was prepared as an account of work sponsored by an agency of the United States Government. Neither the United States Government nor Lawrence Livermore National Security, LLC, nor any of their employees makes any warranty, expressed or implied, or assumes any legal liability or responsibility for the accuracy, completeness, or usefulness of any information, apparatus, product, or process disclosed, or represents that its use would not infringe privately owned rights. Reference herein to any specific commercial product, process, or service by trade name, trademark, manufacturer, or otherwise does not necessarily constitute or imply its endorsement, recommendation, or favoring by the United States Government or Lawrence Livermore National Security, LLC. The views and opinions of authors expressed herein do not necessarily state or reflect those of the United States Government or Lawrence Livermore National Security, LLC, and shall not be used for advertising or product endorsement purposes. ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1743149646.0600467 certipy-0.2.2/PKG-INFO0000644000175100001660000001300314771455116013702 0ustar00runnerdockerMetadata-Version: 2.4 Name: certipy Version: 0.2.2 Summary: Utility to create and sign CAs and certificates Author-email: Thomas Mendoza License: BSD 3-Clause License Copyright (c) 2018, Lawrence Livermore National Security, LLC All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. Project-URL: Homepage, https://github.com/LLNL/certipy Keywords: pki,ssl,tls,certificates Classifier: Development Status :: 4 - Beta Classifier: Intended Audience :: Developers Classifier: Topic :: Utilities Classifier: License :: OSI Approved :: BSD License Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.7 Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Requires-Python: >=3.7 Description-Content-Type: text/markdown License-File: LICENSE License-File: NOTICE Requires-Dist: cryptography Provides-Extra: dev Requires-Dist: pytest; extra == "dev" Requires-Dist: flask; extra == "dev" Requires-Dist: build; extra == "dev" Requires-Dist: pre-commit; extra == "dev" Requires-Dist: ruff; extra == "dev" Requires-Dist: bump-my-version; extra == "dev" Dynamic: license-file # Certipy A simple python tool for creating certificate authorities and certificates on the fly. ## Introduction Certipy was made to simplify the certificate creation process. To that end, Certipy exposes methods for creating and managing certificate authorities, certificates, signing and building trust bundles. Behind the scenes Certipy: * Manages records of all certificates it creates * External certs can be imported and managed by Certipy * Maintains signing hierarchy * Persists certificates to files with appropriate permissions ## Usage ### Command line Creating a certificate authority: Certipy defaults to writing certs and certipy.json into a folder called `out` in your current directory. ``` $ certipy foo FILES {'ca': '', 'cert': 'out/foo/foo.crt', 'key': 'out/foo/foo.key'} IS_CA True SERIAL 0 SIGNEES None PARENT_CA ``` Creating and signing a key-cert pair: ``` $ certipy bar --ca-name foo FILES {'ca': 'out/foo/foo.crt', 'key': 'out/bar/bar.key', 'cert': 'out/bar/bar.crt'} IS_CA False SERIAL 0 SIGNEES None PARENT_CA foo ``` Removal: ``` certipy --rm bar Deleted: FILES {'ca': 'out/foo/foo.crt', 'key': 'out/bar/bar.key', 'cert': 'out/bar/bar.crt'} IS_CA False SERIAL 0 SIGNEES None PARENT_CA foo ``` ### Code Creating a certificate authority: ``` from certipy import Certipy certipy = Certipy(store_dir='/tmp') certipy.create_ca('foo') record = certipy.store.get_record('foo') ``` Creating and signing a key-cert pair: ``` certipy.create_signed_pair('bar', 'foo') record = certipy.store.get_record('bar') ``` Creating trust: ``` certipy.create_ca_bundle('ca-bundle.crt') # or to trust specific certs only: certipy.create_ca_bundle_for_names('ca-bundle.crt', ['bar']) ``` Removal: ``` record = certipy.remove_files('bar') ``` Records are dicts with the following structure: ``` { 'serial': 0, 'is_ca': true, 'parent_ca': 'ca_name', 'signees': { 'signee_name': 1 }, 'files': { 'key': 'path/to/key.key', 'cert': 'path/to/cert.crt', 'ca': 'path/to/ca.crt', } } ``` The `signees` will be empty for non-CA certificates. The `signees` field is stored as a python `Counter`. These relationships are used to build trust bundles. Information in Certipy is generally passed around as records which point to actual files. For most `_record` methods, there are generally equivalent `_file` methods that operate on files themselves. The former will only affect records in Certipy's store and the latter will affect both (something happens to the file, the record for it should change, too). ### Release Certipy is released under BSD license. For more details see the LICENSE file. LLNL-CODE-754897 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1743149635.0 certipy-0.2.2/README.md0000644000175100001660000000515614771455103014072 0ustar00runnerdocker# Certipy A simple python tool for creating certificate authorities and certificates on the fly. ## Introduction Certipy was made to simplify the certificate creation process. To that end, Certipy exposes methods for creating and managing certificate authorities, certificates, signing and building trust bundles. Behind the scenes Certipy: * Manages records of all certificates it creates * External certs can be imported and managed by Certipy * Maintains signing hierarchy * Persists certificates to files with appropriate permissions ## Usage ### Command line Creating a certificate authority: Certipy defaults to writing certs and certipy.json into a folder called `out` in your current directory. ``` $ certipy foo FILES {'ca': '', 'cert': 'out/foo/foo.crt', 'key': 'out/foo/foo.key'} IS_CA True SERIAL 0 SIGNEES None PARENT_CA ``` Creating and signing a key-cert pair: ``` $ certipy bar --ca-name foo FILES {'ca': 'out/foo/foo.crt', 'key': 'out/bar/bar.key', 'cert': 'out/bar/bar.crt'} IS_CA False SERIAL 0 SIGNEES None PARENT_CA foo ``` Removal: ``` certipy --rm bar Deleted: FILES {'ca': 'out/foo/foo.crt', 'key': 'out/bar/bar.key', 'cert': 'out/bar/bar.crt'} IS_CA False SERIAL 0 SIGNEES None PARENT_CA foo ``` ### Code Creating a certificate authority: ``` from certipy import Certipy certipy = Certipy(store_dir='/tmp') certipy.create_ca('foo') record = certipy.store.get_record('foo') ``` Creating and signing a key-cert pair: ``` certipy.create_signed_pair('bar', 'foo') record = certipy.store.get_record('bar') ``` Creating trust: ``` certipy.create_ca_bundle('ca-bundle.crt') # or to trust specific certs only: certipy.create_ca_bundle_for_names('ca-bundle.crt', ['bar']) ``` Removal: ``` record = certipy.remove_files('bar') ``` Records are dicts with the following structure: ``` { 'serial': 0, 'is_ca': true, 'parent_ca': 'ca_name', 'signees': { 'signee_name': 1 }, 'files': { 'key': 'path/to/key.key', 'cert': 'path/to/cert.crt', 'ca': 'path/to/ca.crt', } } ``` The `signees` will be empty for non-CA certificates. The `signees` field is stored as a python `Counter`. These relationships are used to build trust bundles. Information in Certipy is generally passed around as records which point to actual files. For most `_record` methods, there are generally equivalent `_file` methods that operate on files themselves. The former will only affect records in Certipy's store and the latter will affect both (something happens to the file, the record for it should change, too). ### Release Certipy is released under BSD license. For more details see the LICENSE file. LLNL-CODE-754897 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1743149646.0580466 certipy-0.2.2/certipy/0000755000175100001660000000000014771455116014267 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1743149635.0 certipy-0.2.2/certipy/__init__.py0000644000175100001660000000160714771455103016400 0ustar00runnerdocker############################################################################### # Copyright (c) 2018, Lawrence Livermore National Security, LLC # Produced at the Lawrence Livermore National Laboratory # Written by Thomas Mendoza mendoza33@llnl.gov # LLNL-CODE-754897 # All rights reserved # # This file is part of Certipy: https://github.com/LLNL/certipy # # SPDX-License-Identifier: BSD-3-Clause ############################################################################### from certipy.certipy import ( TLSFileType, TLSFile, TLSFileBundle, CertStore, open_tls_file, KeyType, CertNotFoundError, CertExistsError, CertificateAuthorityInUseError, Certipy, ) __all__ = [ TLSFileType, TLSFile, TLSFileBundle, CertStore, open_tls_file, KeyType, CertNotFoundError, CertExistsError, CertificateAuthorityInUseError, Certipy, ] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1743149635.0 certipy-0.2.2/certipy/__main__.py0000644000175100001660000000011514771455103016352 0ustar00runnerdockerfrom certipy.command_line import main if __name__ == "__main__": main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1743149635.0 certipy-0.2.2/certipy/certipy.py0000644000175100001660000007475114771455103016332 0ustar00runnerdocker############################################################################### # Copyright (c) 2018, Lawrence Livermore National Security, LLC # Produced at the Lawrence Livermore National Laboratory # Written by Thomas Mendoza mendoza33@llnl.gov # LLNL-CODE-754897 # All rights reserved # # This file is part of Certipy: https://github.com/LLNL/certipy # # SPDX-License-Identifier: BSD-3-Clause ############################################################################### import os import json import shutil import warnings from enum import Enum from functools import partial from collections import Counter from contextlib import contextmanager from datetime import datetime, timedelta, timezone from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa from cryptography import x509 from ipaddress import ip_address class KeyType(Enum): """Enum for available key types""" rsa = "rsa" # not supported (widely deprecated) # dsa = 'dsa' # not supported (yet) # ecdsa = 'ecdsa' class TLSFileType(Enum): KEY = "key" CERT = "cert" CA = "ca" def _make_oid_map(enum_cls): """Make a mapping of name: OIDClass from an Enum for easy lookup of e.g. 'subjectAltName': x509.ExtensionOID.SubjectAlternativeName """ mapping = {} for name in dir(enum_cls): if name.startswith("_"): continue value = getattr(enum_cls, name) if isinstance(value, x509.ObjectIdentifier): mapping[value._name] = value return mapping # mapping of CN to NameOID.COUNTRY_NAME _name_oid_map = _make_oid_map(x509.oid.NameOID) # short names _name_oid_map["C"] = x509.oid.NameOID.COUNTRY_NAME _name_oid_map["ST"] = x509.oid.NameOID.STATE_OR_PROVINCE_NAME _name_oid_map["L"] = x509.oid.NameOID.LOCALITY_NAME _name_oid_map["O"] = x509.oid.NameOID.ORGANIZATION_NAME _name_oid_map["OU"] = x509.oid.NameOID.ORGANIZATIONAL_UNIT_NAME _name_oid_map["CN"] = x509.oid.NameOID.COMMON_NAME _ext_oid_map = _make_oid_map(x509.oid.ExtensionOID) def _altname(name): """Construct a subjectAltName field from an OpenSSL-style string turns IP:1.2.3.4 into x509.IPAddress('1.2.3.4') """ key, _, value = name.partition(":") # are these case sensitive? I don't find a spec, # in practice only IP and DNS are used. if key == "IP": return x509.IPAddress(ip_address(value)) elif key == "DNS": return x509.DNSName(value) elif key == "email": return x509.RFC822Name(value) elif key == "URI": return x509.UniformResourceIdentifier(value) elif key == "RID": return x509.RegisteredID(value) elif key == "dirName": return x509.DirectoryName(value) else: raise ValueError(f"Unrecognized subjectAltName prefix {key!r} in {name!r}") class CertNotFoundError(Exception): def __init__(self, message, errors=None): super().__init__(message) self.errors = errors class CertExistsError(Exception): def __init__(self, message, errors=None): super().__init__(message) self.errors = errors class CertificateAuthorityInUseError(Exception): def __init__(self, message, errors=None): super().__init__(message) self.errors = errors @contextmanager def open_tls_file(file_path, mode, private=True): """Context to ensure correct file permissions for certs and directories Ensures: - A containing directory with appropriate permissions - Correct file permissions based on what the file is (0o600 for keys and 0o644 for certs) """ containing_dir = os.path.dirname(file_path) fh = None try: if "w" in mode: os.chmod(containing_dir, mode=0o755) fh = open(file_path, mode) except OSError: if "w" in mode: os.makedirs(containing_dir, mode=0o755, exist_ok=True) os.chmod(containing_dir, mode=0o755) fh = open(file_path, "w") else: raise yield fh mode = 0o600 if private else 0o644 os.chmod(file_path, mode=mode) fh.close() class TLSFile: """Describes basic information about files used for TLS""" def __init__( self, file_path, encoding=serialization.Encoding.PEM, file_type=TLSFileType.CERT, x509=None, ): if isinstance(encoding, int): warnings.warn( "OpenSSL.crypto.TYPE_* encoding arguments are deprecated. Use cryptography.hazmat.primitives.serialization.Encoding enum or string 'PEM'", DeprecationWarning, stacklevel=2, ) # match values in OpenSSL.crypto if encoding == 1: # PEM encoding = serialization.Encoding.PEM elif encoding == 2: # ASN / DER encoding = serialization.Encoding.DER self.file_path = file_path self.containing_dir = os.path.dirname(self.file_path) self.encoding = serialization.Encoding(encoding) self.file_type = file_type self.x509 = x509 def __str__(self): data = "" if not self.x509: self.load() if self.file_type is TLSFileType.KEY: data = self.x509.private_bytes( encoding=self.encoding, format=serialization.PrivateFormat.TraditionalOpenSSL, encryption_algorithm=serialization.NoEncryption(), ) else: data = self.x509.public_bytes(self.encoding) return data.decode("utf-8") def get_extension_value(self, ext_name): if self.is_private(): return if not self.x509: self.load() if isinstance(ext_name, str): # string name, lookup OID ext_oid = _ext_oid_map[ext_name] elif hasattr(ext_name, "oid"): # given ExtensionType, get OID ext_oid = ext_name.oid else: # otherwise, assume given OID ext_oid = ext_name try: return self.x509.extensions.get_extension_for_oid(ext_oid).value except x509.ExtensionNotFound: return None def is_ca(self): if self.is_private(): return False if not self.x509: self.load() try: basic_constraints = self.x509.get_extension_for_class(x509.BasicConstraints) except x509.ExtensionNotFound: return False else: return basic_constraints.ca def is_private(self): """Is this a private key""" return True if self.file_type is TLSFileType.KEY else False def load(self): """Load from a file and return an x509 object""" private = self.is_private() if private: if self.encoding == serialization.Encoding.DER: load = serialization.load_der_private_key else: load = serialization.load_pem_private_key load = partial(load, password=None) else: if self.encoding == serialization.Encoding.DER: load = x509.load_der_x509_certificate else: load = x509.load_pem_x509_certificate with open_tls_file(self.file_path, "rb", private=private) as fh: self.x509 = load(fh.read()) return self.x509 def save(self, x509): """Persist this x509 object to disk""" self.x509 = x509 with open_tls_file(self.file_path, "w", private=self.is_private()) as fh: fh.write(str(self)) class TLSFileBundle: """Maintains information that is shared by a set of TLSFiles""" def __init__( self, common_name, files=None, x509s=None, serial=0, is_ca=False, parent_ca="", signees=None, ): self.record = {} self.record["serial"] = serial self.record["is_ca"] = is_ca self.record["parent_ca"] = parent_ca self.record["signees"] = signees for t in TLSFileType: setattr(self, t.value, None) files = files or {} x509s = x509s or {} self._setup_tls_files(files) self.save_x509s(x509s) def _setup_tls_files(self, files): """Initiates TLSFIle objects with the paths given to this bundle""" for file_type in TLSFileType: if file_type.value in files: file_path = files[file_type.value] setattr(self, file_type.value, TLSFile(file_path, file_type=file_type)) def save_x509s(self, x509s): """Saves the x509 objects to the paths known by this bundle""" for file_type in TLSFileType: if file_type.value in x509s: x509 = x509s[file_type.value] if file_type is not TLSFileType.CA: # persist this key or cert to disk tlsfile = getattr(self, file_type.value) if tlsfile: tlsfile.save(x509) def load_all(self): """Utility to load bring all files into memory""" for t in TLSFileType: self[t.value].load() return self def is_ca(self): """Is this bundle for a CA certificate""" return self.record["is_ca"] def to_record(self): """Create a CertStore record from this TLSFileBundle""" tf_list = [getattr(self, k, None) for k in [_.value for _ in TLSFileType]] # If a cert isn't defined in this bundle, remove it tf_list = filter(lambda x: x, tf_list) files = {tf.file_type.value: tf.file_path for tf in tf_list} self.record["files"] = files return self.record def from_record(self, record): """Build a bundle from a CertStore record""" self.record = record self._setup_tls_files(self.record["files"]) return self class CertStore: """Maintains records of certificates created by Certipy Minimally, each record keyed by common name needs: - file - path - type - serial number - parent CA - signees Common names, for the sake of simplicity, are assumed to be unique. If a pair of certs need to be valid for the same IP/DNS address (ex: localhost), that information can be specified in the Subject Alternative Name field. """ def __init__( self, containing_dir="out", store_file="certipy.json", remove_existing=False ): self.store = {} self.containing_dir = containing_dir self.store_file_path = os.path.join(containing_dir, store_file) try: if remove_existing: shutil.rmtree(containing_dir) os.stat(containing_dir) self.load() except FileNotFoundError: os.makedirs(containing_dir, mode=0o755, exist_ok=True) finally: os.chmod(containing_dir, mode=0o755) def save(self): """Write the store dict to a file specified by store_file_path""" with open(self.store_file_path, "w") as fh: fh.write(json.dumps(self.store, indent=4)) def load(self): """Read the store dict from file""" with open(self.store_file_path, "r") as fh: self.store = json.loads(fh.read()) def get_record(self, common_name): """Return the record associated with this common name In most cases, all that's really needed to use an existing cert are the file paths to the files that make up that cert. This method returns just that and doesn't bother loading the associated files. """ try: record = self.store[common_name] return record except KeyError as e: raise CertNotFoundError( "Unable to find record of {name}".format(name=common_name), errors=e ) def get_files(self, common_name): """Return a bundle of TLS files associated with a common name""" record = self.get_record(common_name) return TLSFileBundle(common_name).from_record(record) def add_record( self, common_name, serial=0, parent_ca="", signees=None, files=None, record=None, is_ca=False, overwrite=False, ): """Manually create a record of certs Generally, Certipy can be left to manage certificate locations and storage, but it is occasionally useful to keep track of a set of certs that were created externally (for example, let's encrypt) """ if not overwrite: try: self.get_record(common_name) raise CertExistsError( "Certificate {name} already exists!" " Set overwrite=True to force add.".format(name=common_name) ) except CertNotFoundError: pass record = record or { "serial": serial, "is_ca": is_ca, "parent_ca": parent_ca, "signees": signees, "files": files, } self.store[common_name] = record self.save() def add_files( self, common_name, x509s, files=None, parent_ca="", is_ca=False, signees=None, serial=0, overwrite=False, ): """Add a set files comprising a certificate to Certipy Used with all the defaults, Certipy will manage creation of file paths to be used to store these files to disk and automatically calls save on all TLSFiles that it creates (and where it makes sense to). """ if common_name in self.store and not overwrite: raise CertExistsError( "Certificate {name} already exists!" " Set overwrite=True to force add.".format(name=common_name) ) elif common_name in self.store and overwrite: record = self.get_record(common_name) serial = int(record["serial"]) record["serial"] = serial + 1 TLSFileBundle(common_name).from_record(record).save_x509s(x509s) else: file_base_tmpl = "{prefix}/{cn}/{cn}" file_base = file_base_tmpl.format( prefix=self.containing_dir, cn=common_name ) try: ca_record = self.get_record(parent_ca) ca_file = ca_record["files"]["cert"] except CertNotFoundError: ca_file = "" files = files or { "key": file_base + ".key", "cert": file_base + ".crt", "ca": ca_file, } bundle = TLSFileBundle( common_name, files=files, x509s=x509s, is_ca=is_ca, serial=serial, parent_ca=parent_ca, signees=signees, ) self.store[common_name] = bundle.to_record() self.save() def add_sign_link(self, ca_name, signee_name): """Adds to the CA signees and a parent ref to the signee""" ca_record = self.get_record(ca_name) signee_record = self.get_record(signee_name) signees = ca_record["signees"] or {} signees = Counter(signees) if signee_name not in signees: signees[signee_name] = 1 ca_record["signees"] = signees signee_record["parent_ca"] = ca_name self.save() def remove_sign_link(self, ca_name, signee_name): """Removes signee_name to the signee list for ca_name""" ca_record = self.get_record(ca_name) signee_record = self.get_record(signee_name) signees = ca_record["signees"] or {} signees = Counter(signees) if signee_name in signees: signees[signee_name] = 0 ca_record["signees"] = signees signee_record["parent_ca"] = "" self.save() def update_record(self, common_name, **fields): """Update fields in an existing record""" record = self.get_record(common_name) if fields is not None: for field, value in fields: record[field] = value self.save() return record def remove_record(self, common_name): """Delete the record associated with this common name""" bundle = self.get_files(common_name) num_signees = len(Counter(bundle.record["signees"])) if bundle.is_ca() and num_signees > 0: raise CertificateAuthorityInUseError( "Authority {name} has signed {x} certificates".format( name=common_name, x=num_signees ) ) try: ca_name = bundle.record["parent_ca"] self.get_record(ca_name) self.remove_sign_link(ca_name, common_name) except CertNotFoundError: pass record_copy = dict(self.store[common_name]) del self.store[common_name] self.save() return record_copy def remove_files(self, common_name, delete_dir=False): """Delete files and record associated with this common name""" record = self.remove_record(common_name) if delete_dir: delete_dirs = [] if "files" in record: key_containing_dir = os.path.dirname(record["files"]["key"]) delete_dirs.append(key_containing_dir) cert_containing_dir = os.path.dirname(record["files"]["cert"]) if key_containing_dir != cert_containing_dir: delete_dirs.append(cert_containing_dir) for d in delete_dirs: shutil.rmtree(d) return record class Certipy: def __init__( self, store_dir="out", store_file="certipy.json", remove_existing=False ): self.store = CertStore( containing_dir=store_dir, store_file=store_file, remove_existing=remove_existing, ) def create_key_pair(self, cert_type, bits): """ Create a public/private key pair. Arguments: type - Key type, must be one of KeyType (currently only 'rsa') bits - Number of bits to use in the key Returns: The cryptography private_key keypair object """ if isinstance(cert_type, int): warnings.warn( "Certipy support for PyOpenSSL is deprecated. Use `cert_type='rsa'", DeprecationWarning, stacklevel=2, ) if cert_type == 6: cert_type = KeyType.rsa elif cert_type == 116: raise ValueError("DSA keys are no longer supported. Use 'rsa'.") # this will raise on unrecognized values key_type = KeyType(cert_type) if key_type == KeyType.rsa: key = rsa.generate_private_key( public_exponent=65537, key_size=bits, ) else: raise ValueError(f"Only cert_type='rsa' is supported, not {cert_type!r}") return key def create_request(self, pkey, digest="sha256", **name): """ Create a certificate request. Arguments: pkey - The key to associate with the request digest - Digestion method to use for signing, default is sha256 **name - The name of the subject of the request, possible arguments are: C - Country name ST - State or province name L - Locality name O - Organization name OU - Organizational unit name CN - Common name emailAddress - E-mail address Returns: The certificate request in an X509Req object """ csr = x509.CertificateSigningRequestBuilder() if name is not None: name_attrs = [ x509.NameAttribute(_name_oid_map[key], value) for key, value in name.items() ] csr = csr.subject_name(x509.Name(name_attrs)) algorithm = getattr(hashes, digest.upper())() return csr.sign(pkey, algorithm) def sign( self, req, issuer_cert_key, validity_period, digest="sha256", extensions=None, serial=0, ): """ Generate a certificate given a certificate request. Arguments: req - Certificate request to use issuer_cert - The certificate of the issuer issuer_key - The private key of the issuer not_before - Timestamp (relative to now) when the certificate starts being valid not_after - Timestamp (relative to now) when the certificate stops being valid digest - Digest method to use for signing, default is sha256 Returns: The signed certificate in an X509 object """ issuer_cert, issuer_key = issuer_cert_key not_before, not_after = validity_period now = datetime.now(timezone.utc) if not isinstance(not_before, datetime): # backward-compatibility: integer seconds from now not_before = now + timedelta(seconds=not_before) if not isinstance(not_after, datetime): not_after = now + timedelta(seconds=not_after) cert_builder = ( x509.CertificateBuilder() .subject_name(req.subject) .serial_number(serial or x509.random_serial_number()) .not_valid_before(not_before) .not_valid_after(not_after) .issuer_name(issuer_cert.subject) .public_key(req.public_key()) ) if extensions: for ext, critical in extensions: cert_builder = cert_builder.add_extension(ext, critical=critical) algorithm = getattr(hashes, digest.upper())() cert = cert_builder.sign(issuer_key, algorithm=algorithm) return cert def create_ca_bundle_for_names(self, bundle_name, names): """Create a CA bundle to trust only certs defined in names""" records = [rec for name, rec in self.store.store.items() if name in names] return self.create_bundle(bundle_name, names=[r["parent_ca"] for r in records]) def create_ca_bundle(self, bundle_name, ca_names=None): """ Create a bundle of CA public certs for trust distribution Deprecated: 0.1.2 Arguments: ca_names - The names of CAs to include in the bundle bundle_name - The name of the bundle file to output Returns: Path to the bundle file """ return self.create_bundle(bundle_name, names=ca_names) def create_bundle(self, bundle_name, names=None, ca_only=True): """Create a bundle of public certs for trust distribution This will create a bundle of both CAs and/or regular certificates. Arguments: names - The names of certs to include in the bundle bundle_name - The name of the bundle file to output Returns: Path to the bundle file """ if not names: if ca_only: names = [] for name, record in self.store.store.items(): if record["is_ca"]: names.append(name) else: names = self.store.store.keys() out_file_path = os.path.join(self.store.containing_dir, bundle_name) with open(out_file_path, "w") as fh: for name in names: bundle = self.store.get_files(name) bundle.cert.load() fh.write(str(bundle.cert)) return out_file_path def trust_from_graph(self, graph): """Create a set of trust bundles from a relationship graph. Components in this sense are defined by unique CAs. This method assists in setting up complicated trust between various components that need to do TLS auth. Arguments: graph - dict component:list(components) Returns: dict component:trust bundle file path """ # Ensure there are CAs backing all graph components def distinct_components(graph): """Return a set of components from the provided graph.""" components = set(graph.keys()) for trusts in graph.values(): components |= set(trusts) return components # Default to creating a CA (incapable of signing intermediaries) to # identify a component not known to Certipy for component in distinct_components(graph): try: self.store.get_record(component) except CertNotFoundError: self.create_ca(component) # Build bundles from the graph trust_files = {} for component, trusts in graph.items(): file_name = component + "_trust.crt" trust_files[component] = self.create_bundle( file_name, names=trusts, ca_only=False ) return trust_files def create_ca( self, name, ca_name="", cert_type=KeyType.rsa, bits=2048, alt_names=None, years=5, serial=0, pathlen=0, overwrite=False, ): """ Create a certificate authority Arguments: name - The name of the CA cert_type - The type of the cert. Always 'rsa'. bits - The number of bits to use alt_names - An array of alternative names in the format: IP:address, DNS:address Returns: KeyCertPair for the new CA """ cakey = self.create_key_pair(cert_type, bits) req = self.create_request(cakey, CN=name) signing_key = cakey signing_cert = req if pathlen is not None and pathlen < 0: warnings.warn( "negative pathlen is deprecated. Use pathlen=None", DeprecationWarning, stacklevel=2, ) pathlen = None parent_ca = "" if ca_name: ca_bundle = self.store.get_files(ca_name) signing_key = ca_bundle.key.load() signing_cert = ca_bundle.cert.load() parent_ca = ca_bundle.cert.file_path extensions = [ (x509.BasicConstraints(True, pathlen), True), ( x509.KeyUsage( key_cert_sign=True, crl_sign=True, digital_signature=False, content_commitment=False, key_encipherment=False, data_encipherment=False, key_agreement=False, encipher_only=False, decipher_only=False, ), True, ), ( x509.ExtendedKeyUsage( [ x509.ExtendedKeyUsageOID.SERVER_AUTH, x509.ExtendedKeyUsageOID.CLIENT_AUTH, ] ), True, ), (x509.SubjectKeyIdentifier.from_public_key(cakey.public_key()), False), ( x509.AuthorityKeyIdentifier.from_issuer_public_key( signing_cert.public_key() ), False, ), ] if alt_names: extensions.append( ( x509.SubjectAlternativeName([_altname(name) for name in alt_names]), False, ) ) # TODO: start time before today for clock skew? cacert = self.sign( req, (signing_cert, signing_key), (0, 60 * 60 * 24 * 365 * years), extensions=extensions, serial=serial, ) x509s = {"key": cakey, "cert": cacert, "ca": cacert} self.store.add_files( name, x509s, overwrite=overwrite, parent_ca=parent_ca, is_ca=True, serial=serial, ) if ca_name: self.store.add_sign_link(ca_name, name) return self.store.get_record(name) def create_signed_pair( self, name, ca_name, cert_type=KeyType.rsa, bits=2048, years=5, alt_names=None, serial=0, overwrite=False, ): """ Create a key-cert pair Arguments: name - The name of the key-cert pair ca_name - The name of the CA to sign this cert cert_type - The type of the cert. Always 'rsa' bits - The number of bits to use alt_names - An array of alternative names in the format: IP:address, DNS:address Returns: KeyCertPair for the new signed pair """ key = self.create_key_pair(cert_type, bits) req = self.create_request(key, CN=name) extensions = [ ( x509.ExtendedKeyUsage( [ x509.ExtendedKeyUsageOID.SERVER_AUTH, x509.ExtendedKeyUsageOID.CLIENT_AUTH, ] ), True, ), ] if alt_names: extensions.append( ( x509.SubjectAlternativeName([_altname(name) for name in alt_names]), False, ) ) ca_bundle = self.store.get_files(ca_name) cacert = ca_bundle.cert.load() cakey = ca_bundle.key.load() extensions.append( (x509.AuthorityKeyIdentifier.from_issuer_public_key(cacert.public_key()), False) ) now = datetime.now(timezone.utc) eol = now + timedelta(days=years * 365) cert = self.sign( req, (cacert, cakey), (now, eol), extensions=extensions, serial=serial ) x509s = {"key": key, "cert": cert, "ca": None} self.store.add_files( name, x509s, parent_ca=ca_name, overwrite=overwrite, serial=serial ) # Relate these certs as being parent and child self.store.add_sign_link(ca_name, name) return self.store.get_record(name) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1743149635.0 certipy-0.2.2/certipy/command_line.py0000644000175100001660000000721114771455103017263 0ustar00runnerdocker############################################################################### # Copyright (c) 2018, Lawrence Livermore National Security, LLC # Produced at the Lawrence Livermore National Laboratory # Written by Thomas Mendoza mendoza33@llnl.gov # LLNL-CODE-754897 # All rights reserved # # This file is part of Certipy: https://github.com/LLNL/certipy # # SPDX-License-Identifier: BSD-3-Clause ############################################################################### import argparse import sys from certipy import ( Certipy, CertExistsError, CertificateAuthorityInUseError, KeyType, ) def main(): describe_certipy = """ Certipy: Create simple, self-signed certificate authorities and certs. """ parser = argparse.ArgumentParser(description=describe_certipy) parser.add_argument( "name", help="""Name of the cert to create, defaults to creating a CA cert. If no signing --ca-name specified.""", ) parser.add_argument( "--ca-name", help="The name of the CA to sign this cert.", default="" ) parser.add_argument( "--overwrite", action="store_true", help="If the cert already exists, bump the serial and overwrite it.", ) parser.add_argument( "--rm", action="store_true", help="Remove the cert specified by name." ) parser.add_argument( "--cert-type", default="rsa", choices=[t.value for t in KeyType], help="The type of key to create.", ) parser.add_argument( "--bits", type=int, default=2048, help="The number of bits to use." ) parser.add_argument( "--valid", type=int, default=5, help="Years the cert is valid for." ) parser.add_argument( "--alt-names", default="", help="Alt names for the certificate (comma delimited).", ) parser.add_argument( "--store-dir", default="out", help="The location for the store and certs." ) args = parser.parse_args() certipy = Certipy(store_dir=args.store_dir) record = None if args.rm: try: record = certipy.store.remove_files(args.name, delete_dir=True) print("Deleted:") for key, val in record.items(): print(key.upper(), val) except CertificateAuthorityInUseError as e: print("Unable to delete.", e) sys.exit(0) alt_names = None if args.alt_names: alt_names = [_.strip() for _ in args.alt_names.split(",")] if args.ca_name: ca_record = certipy.store.get_record(args.ca_name) if ca_record: try: record = certipy.create_signed_pair( args.name, args.ca_name, cert_type=args.cert_type, bits=args.bits, years=args.valid, alt_names=alt_names, overwrite=args.overwrite, ) except CertExistsError as e: print(e) else: print( "CA {} not found. Must specify an exisiting authority to" " sign this cert.".format(args.ca_name) ) else: try: record = certipy.create_ca( args.name, cert_type=args.cert_type, bits=args.bits, years=args.valid, alt_names=alt_names, overwrite=args.overwrite, ) except CertExistsError as e: print(e) if record: for key, val in record.items(): print(key.upper(), val) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1743149646.0590465 certipy-0.2.2/certipy/test/0000755000175100001660000000000014771455116015246 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1743149635.0 certipy-0.2.2/certipy/test/__init__.py0000644000175100001660000000073514771455103017360 0ustar00runnerdocker############################################################################### # Copyright (c) 2018, Lawrence Livermore National Security, LLC # Produced at the Lawrence Livermore National Laboratory # Written by Thomas Mendoza mendoza33@llnl.gov # LLNL-CODE-754897 # All rights reserved # # This file is part of Certipy: https://github.com/LLNL/certipy # # SPDX-License-Identifier: BSD-3-Clause ############################################################################### ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1743149635.0 certipy-0.2.2/certipy/test/test_certipy.py0000644000175100001660000003064414771455103020341 0ustar00runnerdocker############################################################################### # Copyright (c) 2018, Lawrence Livermore National Security, LLC # Produced at the Lawrence Livermore National Laboratory # Written by Thomas Mendoza mendoza33@llnl.gov # LLNL-CODE-754897 # All rights reserved # # This file is part of Certipy: https://github.com/LLNL/certipy # # SPDX-License-Identifier: BSD-3-Clause ############################################################################### import os import pytest import socket import ssl from urllib.request import urlopen, URLError from contextlib import closing, contextmanager from datetime import datetime, timedelta, timezone from flask import Flask from tempfile import TemporaryDirectory from threading import Thread from werkzeug.serving import make_server from pytest import fixture from cryptography import x509 from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa from ipaddress import ip_address from ..certipy import ( TLSFileType, TLSFile, TLSFileBundle, CertStore, open_tls_file, Certipy, ) def find_free_port(): with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: s.bind(("localhost", 0)) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) return s.getsockname()[1] def make_flask_app(): app = Flask(__name__) @app.route("/") def working(): return "working" return app @contextmanager def tls_server(certfile: str, keyfile: str, host: str = "localhost", port: int = 0): if port == 0: port = find_free_port() ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ssl_context.load_cert_chain(certfile, keyfile) server = make_server( host, port, make_flask_app(), ssl_context=ssl_context, threaded=True ) t = Thread(target=server.serve_forever) t.start() try: yield server finally: server.shutdown() @fixture def fake_cert_file(tmp_path): sub_dir = tmp_path / "certipy" sub_dir.mkdir() filename = sub_dir / "foo.crt" filename.touch() return filename @fixture(scope="module") def signed_key_pair(): pkey = rsa.generate_private_key( public_exponent=65537, key_size=2048, ) subject = issuer = x509.Name([x509.NameAttribute(x509.NameOID.COMMON_NAME, "test")]) cert = ( x509.CertificateBuilder() .subject_name(subject) .issuer_name(issuer) .public_key(pkey.public_key()) .serial_number(1) .not_valid_before(datetime.now(timezone.utc)) .not_valid_after(datetime.now(timezone.utc) + timedelta(days=365 * 2)) .sign(pkey, hashes.SHA256()) ) return (pkey, cert) @fixture(scope="module") def record(): return { "serial": 1, "parent_ca": "", "signees": None, "files": { "key": "out/foo.key", "cert": "out/foo.crt", "ca": "out/ca.crt", }, } def test_tls_context_manager(fake_cert_file): def simple_perms(f): return oct(os.stat(f).st_mode & 0o777) # read with pytest.raises(OSError): with open_tls_file("foo.test", "r"): pass with open_tls_file(fake_cert_file, "r"): pass # write containing_dir = os.path.dirname(fake_cert_file) # public certificate with open_tls_file(fake_cert_file, "w", private=False): assert simple_perms(containing_dir) == "0o755" assert simple_perms(fake_cert_file) == "0o644" # private certificate with open_tls_file(fake_cert_file, "w"): assert simple_perms(containing_dir) == "0o755" assert simple_perms(fake_cert_file) == "0o600" def test_tls_file(signed_key_pair, fake_cert_file): key, cert = signed_key_pair def read_write_key(file_type): tlsfile = TLSFile(fake_cert_file, file_type=file_type) # test persist to disk x509 = cert if file_type is TLSFileType.CERT else key tlsfile.save(x509) with open(fake_cert_file, "r") as f: assert f.read() is not None # test load from disk loaded_tlsfile = TLSFile(fake_cert_file, file_type=file_type) loaded_tlsfile.x509 = tlsfile.load() assert str(loaded_tlsfile) == str(tlsfile) # public key read_write_key(TLSFileType.CERT) # private key read_write_key(TLSFileType.KEY) def test_tls_file_bundle(signed_key_pair, record): key, cert = signed_key_pair # from record bundle = TLSFileBundle("foo").from_record(record) assert bundle.key and bundle.cert and bundle.ca # to record exported_record = bundle.to_record() f_types = {key for key in exported_record["files"].keys()} assert len(f_types) == 3 assert f_types == {"key", "cert", "ca"} def test_certipy_store(signed_key_pair, record): key, cert = signed_key_pair key_str = key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.TraditionalOpenSSL, encryption_algorithm=serialization.NoEncryption(), ).decode("utf-8") cert_str = cert.public_bytes(serialization.Encoding.PEM).decode("utf-8") with TemporaryDirectory() as td: common_name = "foo" store = CertStore(containing_dir=td) # add files x509s = { "key": key, "cert": cert, "ca": None, } store.add_files(common_name, x509s) # check the TLSFiles bundle = store.get_files(common_name) bundle.key.load() bundle.cert.load() assert key_str == str(bundle.key) assert cert_str == str(bundle.cert) # save the store records to a file store.save() # read the records back in store.load() # check the record for those files main_record = store.get_record(common_name) non_empty_paths = [f for f in main_record["files"].values() if f] assert len(non_empty_paths) == 2 # add another record with no physical files signee_common_name = "bar" store.add_record(signee_common_name, record=record) # 'sign' cert store.add_sign_link(common_name, signee_common_name) signee_record = store.get_record(signee_common_name) assert len(main_record["signees"]) == 1 assert signee_record["parent_ca"] == common_name def test_certipy(): # FIXME: unfortunately similar names...either separate tests or rename with TemporaryDirectory() as td: # create a CA ca_name = "foo" certipy = Certipy(store_dir=td) ca_record = certipy.create_ca(ca_name, pathlen=None) non_empty_paths = [f for f in ca_record["files"].values() if f] assert len(non_empty_paths) == 2 # check that the paths are backed by actual files ca_bundle = certipy.store.get_files(ca_name) assert ca_bundle.key.load() is not None assert ca_bundle.cert.load() is not None assert "PRIVATE" in str(ca_bundle.key) assert "CERTIFICATE" in str(ca_bundle.cert) # create a cert and sign it with that CA cert_name = "bar" alt_names = ["DNS:bar.example.com", "IP:10.10.10.10"] cert_record = certipy.create_signed_pair( cert_name, ca_name, alt_names=alt_names ) non_empty_paths = [f for f in cert_record["files"].values() if f] assert len(non_empty_paths) == 3 assert cert_record["files"]["ca"] == ca_record["files"]["cert"] cert_bundle = certipy.store.get_files(cert_name) cert_bundle.cert.load() subject_alt = cert_bundle.cert.get_extension_value(x509.SubjectAlternativeName) assert subject_alt is not None assert subject_alt.get_values_for_type(x509.IPAddress) == [ ip_address("10.10.10.10") ] assert subject_alt.get_values_for_type(x509.DNSName) == ["bar.example.com"] # add a second CA ca_name1 = "baz" certipy.create_ca(ca_name1) # create a bundle from all known certs bundle_file_name = "bundle.crt" bundle_file = certipy.create_ca_bundle(bundle_file_name) ca_bundle1 = certipy.store.get_files(ca_name1) ca_bundle1.cert.load() assert bundle_file is not None with open(bundle_file, "r") as fh: all_certs = fh.read() # should contain both CA certs assert str(ca_bundle.cert) in all_certs assert str(ca_bundle1.cert) in all_certs # bundle of CA certs for only a single name this time bundle_file = certipy.create_ca_bundle_for_names(bundle_file_name, ["bar"]) assert bundle_file is not None with open(bundle_file, "r") as fh: all_certs = fh.read() assert str(ca_bundle.cert) in all_certs assert str(ca_bundle1.cert) not in all_certs # delete certs deleted_record = certipy.store.remove_files("bar", delete_dir=True) assert not os.path.exists(deleted_record["files"]["cert"]) assert not os.path.exists(deleted_record["files"]["key"]) # the CA cert should still be around, we have to delete that explicitly assert os.path.exists(deleted_record["files"]["ca"]) # create an intermediate CA begin_ca_signee_num = len(ca_record["signees"] or {}) intermediate_ca = "bat" certipy.create_ca(intermediate_ca, ca_name=ca_name, pathlen=1) end_ca_signee_num = len(ca_record["signees"]) intermediate_ca_bundle = certipy.store.get_files(intermediate_ca) basic_constraints = intermediate_ca_bundle.cert.get_extension_value( "basicConstraints" ) assert end_ca_signee_num > begin_ca_signee_num assert intermediate_ca_bundle.record["parent_ca"] == ca_name assert intermediate_ca_bundle.is_ca() assert basic_constraints.path_length == 1 def test_certipy_trust_graph(): trust_graph = { "foo": ["foo", "bar"], "bar": ["foo"], "baz": ["bar"], } def distinct_components(graph): """Return a set of components from the provided graph.""" components = set(graph.keys()) for trusts in graph.values(): components |= set(trusts) return components with TemporaryDirectory() as td: certipy = Certipy(store_dir=td) # after this, all components in the graph should exist in certipy trust_files = certipy.trust_from_graph(trust_graph) bundles = {} all_components = distinct_components(trust_graph) for component in all_components: bundles[component] = certipy.store.get_files(component) # components should only trust others listed explicitly in the graph for component, trusts in trust_graph.items(): trust_file = trust_files[component] not_trusts = all_components - set(trusts) with open(trust_file) as fh: trust_bundle = fh.read() for trusted_comp in trusts: bundle = bundles[trusted_comp] assert str(bundle.cert) in trust_bundle for untrusted_comp in not_trusts: bundle = bundles[untrusted_comp] assert str(bundle.cert) not in trust_bundle def test_certs(): with TemporaryDirectory() as td: # Setup ca_name = "foo" certipy = Certipy(store_dir=td) ca_record = certipy.create_ca(ca_name, pathlen=-1) cert_name = "bar" alt_names = ["DNS:localhost", "IP:127.0.0.1"] cert_record = certipy.create_signed_pair( cert_name, ca_name, alt_names=alt_names ) with tls_server( cert_record["files"]["cert"], cert_record["files"]["key"] ) as server: # Execute/Verify url = f"https://{server.host}:{server.port}" # Fails without specifying a CA for verification with pytest.raises(URLError, match="SSL"): with urlopen(url): pass ssl_context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=ca_record["files"]["cert"]) ssl_context.verify_mode = ssl.CERT_REQUIRED ssl_context.load_default_certs() ssl_context.load_cert_chain(ca_record["files"]["cert"], ca_record["files"]["key"]) # Succeeds when supplying the CA cert with urlopen(url, context=ssl_context): pass ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1743149635.0 certipy-0.2.2/certipy/version.py0000644000175100001660000000002614771455103016320 0ustar00runnerdocker__version__ = "0.2.2" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1743149646.0590465 certipy-0.2.2/certipy.egg-info/0000755000175100001660000000000014771455116015761 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1743149646.0 certipy-0.2.2/certipy.egg-info/PKG-INFO0000644000175100001660000001300314771455116017053 0ustar00runnerdockerMetadata-Version: 2.4 Name: certipy Version: 0.2.2 Summary: Utility to create and sign CAs and certificates Author-email: Thomas Mendoza License: BSD 3-Clause License Copyright (c) 2018, Lawrence Livermore National Security, LLC All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. Project-URL: Homepage, https://github.com/LLNL/certipy Keywords: pki,ssl,tls,certificates Classifier: Development Status :: 4 - Beta Classifier: Intended Audience :: Developers Classifier: Topic :: Utilities Classifier: License :: OSI Approved :: BSD License Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.7 Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Requires-Python: >=3.7 Description-Content-Type: text/markdown License-File: LICENSE License-File: NOTICE Requires-Dist: cryptography Provides-Extra: dev Requires-Dist: pytest; extra == "dev" Requires-Dist: flask; extra == "dev" Requires-Dist: build; extra == "dev" Requires-Dist: pre-commit; extra == "dev" Requires-Dist: ruff; extra == "dev" Requires-Dist: bump-my-version; extra == "dev" Dynamic: license-file # Certipy A simple python tool for creating certificate authorities and certificates on the fly. ## Introduction Certipy was made to simplify the certificate creation process. To that end, Certipy exposes methods for creating and managing certificate authorities, certificates, signing and building trust bundles. Behind the scenes Certipy: * Manages records of all certificates it creates * External certs can be imported and managed by Certipy * Maintains signing hierarchy * Persists certificates to files with appropriate permissions ## Usage ### Command line Creating a certificate authority: Certipy defaults to writing certs and certipy.json into a folder called `out` in your current directory. ``` $ certipy foo FILES {'ca': '', 'cert': 'out/foo/foo.crt', 'key': 'out/foo/foo.key'} IS_CA True SERIAL 0 SIGNEES None PARENT_CA ``` Creating and signing a key-cert pair: ``` $ certipy bar --ca-name foo FILES {'ca': 'out/foo/foo.crt', 'key': 'out/bar/bar.key', 'cert': 'out/bar/bar.crt'} IS_CA False SERIAL 0 SIGNEES None PARENT_CA foo ``` Removal: ``` certipy --rm bar Deleted: FILES {'ca': 'out/foo/foo.crt', 'key': 'out/bar/bar.key', 'cert': 'out/bar/bar.crt'} IS_CA False SERIAL 0 SIGNEES None PARENT_CA foo ``` ### Code Creating a certificate authority: ``` from certipy import Certipy certipy = Certipy(store_dir='/tmp') certipy.create_ca('foo') record = certipy.store.get_record('foo') ``` Creating and signing a key-cert pair: ``` certipy.create_signed_pair('bar', 'foo') record = certipy.store.get_record('bar') ``` Creating trust: ``` certipy.create_ca_bundle('ca-bundle.crt') # or to trust specific certs only: certipy.create_ca_bundle_for_names('ca-bundle.crt', ['bar']) ``` Removal: ``` record = certipy.remove_files('bar') ``` Records are dicts with the following structure: ``` { 'serial': 0, 'is_ca': true, 'parent_ca': 'ca_name', 'signees': { 'signee_name': 1 }, 'files': { 'key': 'path/to/key.key', 'cert': 'path/to/cert.crt', 'ca': 'path/to/ca.crt', } } ``` The `signees` will be empty for non-CA certificates. The `signees` field is stored as a python `Counter`. These relationships are used to build trust bundles. Information in Certipy is generally passed around as records which point to actual files. For most `_record` methods, there are generally equivalent `_file` methods that operate on files themselves. The former will only affect records in Certipy's store and the latter will affect both (something happens to the file, the record for it should change, too). ### Release Certipy is released under BSD license. For more details see the LICENSE file. LLNL-CODE-754897 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1743149646.0 certipy-0.2.2/certipy.egg-info/SOURCES.txt0000644000175100001660000000074514771455116017653 0ustar00runnerdocker.gitignore .pre-commit-config.yaml LICENSE MANIFEST.in NOTICE README.md pyproject.toml .github/workflows/ci.yml .github/workflows/release.yml certipy/__init__.py certipy/__main__.py certipy/certipy.py certipy/command_line.py certipy/version.py certipy.egg-info/PKG-INFO certipy.egg-info/SOURCES.txt certipy.egg-info/dependency_links.txt certipy.egg-info/entry_points.txt certipy.egg-info/requires.txt certipy.egg-info/top_level.txt certipy/test/__init__.py certipy/test/test_certipy.py././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1743149646.0 certipy-0.2.2/certipy.egg-info/dependency_links.txt0000644000175100001660000000000114771455116022027 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1743149646.0 certipy-0.2.2/certipy.egg-info/entry_points.txt0000644000175100001660000000006614771455116021261 0ustar00runnerdocker[console_scripts] certipy = certipy.command_line:main ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1743149646.0 certipy-0.2.2/certipy.egg-info/requires.txt0000644000175100001660000000010714771455116020357 0ustar00runnerdockercryptography [dev] pytest flask build pre-commit ruff bump-my-version ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1743149646.0 certipy-0.2.2/certipy.egg-info/top_level.txt0000644000175100001660000000001014771455116020502 0ustar00runnerdockercertipy ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1743149635.0 certipy-0.2.2/pyproject.toml0000644000175100001660000000422214771455103015520 0ustar00runnerdocker############################################################################### # Copyright (c) 2018, Lawrence Livermore National Security, LLC # Produced at the Lawrence Livermore National Laboratory # Written by Thomas Mendoza mendoza33@llnl.gov # LLNL-CODE-754897 # All rights reserved # # This file is part of Certipy: https://github.com/LLNL/certipy # # SPDX-License-Identifier: BSD-3-Clause ############################################################################### [build-system] requires = ["setuptools>=64", "setuptools_scm>=7"] build-backend = "setuptools.build_meta" [project] name = "certipy" description = "Utility to create and sign CAs and certificates" dynamic = ["version"] readme = {file = "README.md", content-type = "text/markdown"} license = {file = "LICENSE"} authors = [{name = "Thomas Mendoza", email = "mendoza33@llnl.gov"}] classifiers=[ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Topic :: Utilities", "License :: OSI Approved :: BSD License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12" ] keywords = ["pki", "ssl", "tls", "certificates"] requires-python = ">=3.7" dependencies = ["cryptography"] [project.optional-dependencies] dev = ["pytest", "flask", "build", "pre-commit", "ruff", "bump-my-version"] [tool.setuptools.dynamic] version = {attr = "certipy.version.__version__"} [tool.setuptools.packages.find] include = ["certipy"] [project.scripts] certipy = "certipy.command_line:main" [project.urls] Homepage = "https://github.com/LLNL/certipy" [tool.bumpversion] allow_dirty = false commit = true message = "Bump version: {current_version} → {new_version}" commit_args = "--no-verify" tag = true tag_name = "v{new_version}" tag_message = "Bump version: {current_version} → {new_version}" current_version = "0.2.2" search = "{current_version}" replace = "{new_version}" [[tool.bumpversion.files]] filename = "certipy/version.py" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1743149646.0600467 certipy-0.2.2/setup.cfg0000644000175100001660000000004614771455116014431 0ustar00runnerdocker[egg_info] tag_build = tag_date = 0