1
0
mirror of https://github.com/moepman/acertmgr.git synced 2024-06-02 08:42:33 +02:00

Compare commits

...

37 Commits

Author SHA1 Message Date
9ca6dae048 version: bump to 1.0.5 2023-07-13 15:36:44 +02:00
Kishi85
274351415d Update github action workflow to build debian packages with gzip format for older OS versions 2023-07-12 17:46:06 +02:00
Rudolf Mayerhofer
f78cb5c554 Github Action: Update to newer github action syntax/standards, change image to ubuntu-latest, change pypi-publish to supported version and check if we have credentials to publish at all 2023-07-12 16:10:21 +02:00
Rudolf Mayerhofer
c3736c0838 Allow multiple sets of the same domain to defined in a single config file (necessary for multiple certs using different key_algorithm) in a list style notation (lists of maps) 2023-07-12 16:10:21 +02:00
Rudolf Mayerhofer
1a98f86aad Fix idna conversion for force-renew (probably broken since the IDNA cleanup) 2023-07-12 16:10:21 +02:00
Rudolf Mayerhofer
ef81ea62d1 Unify key_algorithm handling for elipic curves (change naming to ECC but stay backwards compatible) 2023-07-12 16:10:21 +02:00
Rudolf Mayerhofer
d1caaf80ef Fix LOG_REPLACEMENTS determination when multiple domain sets exist and we are on a newer version of python 2023-07-12 16:10:21 +02:00
Rudolf Mayerhofer
ba644d44f1 Update config id if we have a key algorithm set to allow for multiple certs with different algorithms for the same set of domains
This is a breaking change!
Changes the id for configurations with a key algorithm set, which by default results in changes to serveral dependent configuration values as well,
such as cert_file/key_file/csr_file. This will require existing ECC setups to append the ecc suffix to files in the acertmgr configuration directory
2023-07-12 16:10:21 +02:00
c15b6ec441 Instantiate HashAlgorithm in OCSPRequestBuilder
Installations of more recent cryptography require parameter hash
algorithm to be an instance of hashes.HashAlgorithm, not the bare object
itself.

Fixes #63
2023-07-10 19:27:44 +02:00
Kishi85
2d230e30d9 Clarify expected authority format (at least for v2) and add an example 2021-10-31 09:57:31 +01:00
Kishi85
6f0ccfdc91 logging: Add real counterparts of IDNA-mapped domains in brackets 2021-09-20 09:26:47 +02:00
Kishi85
460b0119ac configuration: Simplify too complex IDNA conversion 2021-09-13 09:00:59 +02:00
David Klaftenegger
e2f7b09b18 certs already contain idna domain names
The idna_convert call here does nothing: when reading a certificate, it
already contains idna domain names. Converting them to idna is
equivalent to the identity function, and can thus be removed.
2021-05-30 16:21:54 +02:00
93e28437ff version: bump to 1.0.4 2021-05-21 22:52:34 +02:00
Kishi85
2e1f5cd894 acertmgr/v2: Handle CA certificate chains properly 2021-05-21 22:50:44 +02:00
Kishi85
ce157a5c8a CI: Build on Ubuntu 18.04 while we are Python 2 compatbile and OS version is not EOL 2021-03-23 18:43:07 +01:00
Kishi85
9953cb4527 standalone: Fix multiple challange handlers on same port
If you define challenge handlers on a per-domain basis multiple will be
created. This would cause the standalone handler to potientially try
to bind the same port (when configured) multiple times, which would only
work on the first try. Subsequent tries would fail with "Address already
in use". To fix this only bind the server between start and stop of the
challenge and cleanup afterwards.
2021-03-23 18:43:07 +01:00
7a5d35f29b GitHub Actions: use current setuptools and wheel 2020-10-12 19:22:02 +02:00
62f01aeff9 GitHub Actions: twine upload via pypa/gh-action-pypi-publish 2020-10-12 19:01:09 +02:00
b48f4532b9 reformat setup.py 2020-10-12 17:48:55 +02:00
bc2a7229ec GitHub Actions: unify whitespace style 2020-10-12 17:22:52 +02:00
fd4fed9432 version: bump to 1.0.3 2020-03-12 18:41:15 +01:00
56743dcbb9 GitHub Actions: fix fetching tags 2020-03-04 17:29:11 +01:00
Kishi85
0648cb7b38 tools: Fix IDNA handler (again) 2020-03-04 14:50:05 +01:00
Kishi85
b37d0cad94 acertmgr: Add a OCSP validation to certificate verification 2020-03-04 14:50:05 +01:00
Kishi85
c33a39a433 tools: make pem file writable by owner before tryting to write
A PEM file might not be writable by the owner when it should be written
(e.g. on Windows), so we have to ensure the file has write permissions
before doing so
2020-03-04 14:40:49 +01:00
Kishi85
882ddfd0b8 Generate proper dependencies on deb Packages 2020-02-20 18:40:22 +01:00
Kishi
e5edc4e5aa Use Github Actions for automated building and release 2020-02-20 18:35:41 +01:00
e48724b726 version: bump to 1.0.2 2019-11-23 15:37:07 +01:00
6314f468c1 setup.py: fix package name for yaml 2019-11-08 19:40:05 +01:00
Kishi85
97e9be80cf acertmgr: Fix module/function issues on windows 2019-10-28 10:50:09 +01:00
Kishi85
f5f038d47b configuration: global config is now relative to config_dir 2019-10-26 19:11:33 +02:00
a0a4b0bf07 version: bump to 1.0.1 2019-10-01 13:08:45 +02:00
a63eabd0ee .drone.yml: upload releases to PyPI 2019-10-01 13:08:10 +02:00
2911e05165 setup.py: use proper PyPI supported classifiers 2019-10-01 13:06:37 +02:00
8dad549d68 version: bump to 1.0.0 2019-09-23 14:57:29 +02:00
11d43d4817 build packages via drone.io 2019-09-23 14:57:12 +02:00
10 changed files with 399 additions and 145 deletions

114
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,114 @@
---
name: Build (and release)
on: [ push, pull_request ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Install dependencies
run: |
sudo apt update -qq -y
sudo apt install -qq -y build-essential fakeroot git python-all python3-cryptography python3-pip python3-stdeb python3-wheel twine dh-python
sudo pip3 install --upgrade setuptools wheel
- name: Prepare build process
id: buildprep
run: |
# Fetch tags and determine version
git fetch --tags -f
VER="$(python3 setup.py --version)"
echo "Version found: $VER"
echo "version=$VER" >> $GITHUB_OUTPUT
- name: Build python package using setuptools (source/wheel)
run: |
python3 setup.py sdist --formats=gztar
python3 setup.py bdist_wheel
- name: Prepare stdeb build process
id: stdebprep
run: |
# Patch setup.py to allow stdeb proper debian style builds
sed "s/=determine_version()/='$(python3 setup.py --version)'/gi" -i setup.py
sed "s@('readme'@('share/doc/python3-acertmgr'@" -i setup.py
# Determine recommended dependencies for deb package
echo "recommends3=$(echo "python3-pkg-resources")" >> $GITHUB_OUTPUT
# Find optional dependencies to suggest in deb package
echo "suggests3=$(python3 -c "from setup import extra_requirements; print('\n'.join(['\n'.join(x) for x in extra_requirements.values()]))" | grep -v cryptography | sed 's/PyYAML/yaml/gi' | awk '{ printf("python3-%s, ",$1)};' | awk '{$1=$1; print}')" >> $GITHUB_OUTPUT
- name: Build debian package using setuptools and stdeb
run: |
# Create debianized source directory
python3 setup.py --command-packages=stdeb.command sdist_dsc --with-python2=False --with-python3=True --recommends3="${{ steps.stdebprep.outputs.recommends3 }}" --suggests3="${{ steps.stdebprep.outputs.suggests3 }}"
# Enter debianzied source directory (first sub-directory under deb_dist)
cd "$(find deb_dist -maxdepth 1 -type d | grep -v 'deb_dist$\|tmp_sdist_dsc' | head -n1)"
# Enforce GZIP compressed debian package info for older OS versions
echo -e 'override_dh_builddeb:\n\tdh_builddeb -- -Zgzip\n' >> debian/rules
# Build deb package
DEB_BUILD_OPTIONS=nocheck dpkg-buildpackage -rfakeroot -uc -us
- name: Create a changelog from git log since last non-pre/rc tag
run: |
# Determine current tag and last non-rc/pre tag
CTAG="$(git describe --tags --abbrev=0)"
LTAG="$(git tag | grep -B1 ${CTAG} | head -n1)"
while echo $LTAG | grep -q 'rc\|pre'; do
LTAG="$(git tag | grep -B1 ${LTAG} | head -n1)"
done
# Write changelog
echo "Changes since ${LTAG}:" > changelog.txt
git log --format=' * %s' ${LTAG}..${CTAG} >> changelog.txt
cat changelog.txt
- name: Collect files for artifact upload
run: |
mkdir -v artifacts
cp -v changelog.txt artifacts/
cp -v dist/*.tar.gz artifacts/
cp -v dist/*.whl artifacts/
cp -v deb_dist/*.deb artifacts/
- name: Upload build artifact
uses: actions/upload-artifact@v1
with:
name: ${{ format('acertmgr_build_{0}', steps.buildprep.outputs.version) }}
path: artifacts
- name: Create new GitHub release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
name: ${{ steps.buildprep.outputs.version }}
draft: true
prerelease: ${{ contains(github.ref, 'rc') || contains(github.ref, 'pre') }}
body_path: changelog.txt
files: |
artifacts/*.tar.gz
artifacts/*.whl
artifacts/*.deb
- name: Check PyPI secrets
id: checksecrets
shell: bash
run: |
if [ "$USER" == "" -o "$PASSWORD" == "" ]; then
echo "secretspresent=NO" >> $GITHUB_OUTPUT
else
echo "secretspresent=YES" >> $GITHUB_OUTPUT
fi
env:
USER: ${{ secrets.PYPI_USERNAME }}
PASSWORD: ${{ secrets.PYPI_PASSWORD }}
- name: Create new PyPI release
if: steps.checksecrets.outputs.secretspresent == 'YES' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && (!(contains(github.ref, 'rc') || contains(github.ref, 'pre')))
uses: pypa/gh-action-pypi-publish@release/v1
with:
user: ${{ secrets.PYPI_USERNAME }}
password: ${{ secrets.PYPI_PASSWORD }}

View File

@ -12,7 +12,7 @@ Requirements
------------ ------------
* Python (2.7+ and 3.5+ should work) * Python (2.7+ and 3.5+ should work)
* cryptography>=0.6 (usually includes the optional idna module) * cryptography>=0.6
Optional requirements (to use specified features) Optional requirements (to use specified features)
------------------------------------------------------ ------------------------------------------------------
@ -56,7 +56,7 @@ By default the directory (work_dir) containing the working data (csr,certificate
4 configuration contexts are known (*domainconfig (d) > globalconfig (g) > commandline (c) > built-in defaults*) with the following directives (subject to change, usual usage context written bold): 4 configuration contexts are known (*domainconfig (d) > globalconfig (g) > commandline (c) > built-in defaults*) with the following directives (subject to change, usual usage context written bold):
| Directive | Context | Description | Built-in Default | | Directive | Context | Description | Built-in Default |
| --- | --- | --- | --- | | --- | --- |----------------------------------------------------------------------------------------------------------------------------------------------| --- |
| -c/--config-file | **c** | global configuration file (optional) | /etc/acertmgr/acertmgr.conf | | -c/--config-file | **c** | global configuration file (optional) | /etc/acertmgr/acertmgr.conf |
| -d/--config-dir | **c** | directory containing domain configuration files (ending with .conf, globalconfig will be excluded automatically if in same directory) | /etc/acertmgr/*.conf | | -d/--config-dir | **c** | directory containing domain configuration files (ending with .conf, globalconfig will be excluded automatically if in same directory) | /etc/acertmgr/*.conf |
| -w/--work-dir | **c** | working directory containing csr/certificates/keys/ca files | /etc/acertmgr | | -w/--work-dir | **c** | working directory containing csr/certificates/keys/ca files | /etc/acertmgr |
@ -65,15 +65,16 @@ By default the directory (work_dir) containing the working data (csr,certificate
| --revoke-reason | **c** | Provide a reason code for the revocation (see https://tools.ietf.org/html/rfc5280#section-5.3.1 for valid values) | | | --revoke-reason | **c** | Provide a reason code for the revocation (see https://tools.ietf.org/html/rfc5280#section-5.3.1 for valid values) | |
| domain (san-domain...): | **d** | (domainconfig section start) Domains to use in the cert request. This value will be MD5-hashed as cert_id. | | | domain (san-domain...): | **d** | (domainconfig section start) Domains to use in the cert request. This value will be MD5-hashed as cert_id. | |
| api | d,**g** | Determines the API version used | v2 | | api | d,**g** | Determines the API version used | v2 |
| authority | d,**g** | URL to the certificate authorities API | https://acme-v02.api.letsencrypt.org | | authority | d,**g** | URL to the certificate authorities ACME API root (without trailing /directory or similar) | https://acme-v02.api.letsencrypt.org |
| authority_tos_agreement | d,**g**,c | Indicates agreement to the ToS of the certificate authority (--authority-tos-agreement on command line) | | | authority_tos_agreement | d,**g**,c | Indicates agreement to the ToS of the certificate authority (--authority-tos-agreement on command line) | |
| authority_contact_email | d,**g** | (v2 API only) Contact e-mail to be registered with your account key | | | authority_contact_email | d,**g** | (v2 API only) Contact e-mail to be registered with your account key | |
| account_key | d,**g** | Path to the account key | {work_dir}/account.key | | account_key | d,**g** | Path to the account key | {work_dir}/account.key |
| account_key_algorithm | d,**g** | Key-algorithm for newly generated account keys (RSA, EC, ED25519, ED448) | RSA | | account_key_algorithm | d,**g** | Key-algorithm for newly generated account keys (RSA, EC, ED25519, ED448) | RSA |
| account_key_length | d,**g** | Key-length for newly generated RSA account keys (in bits) or EC curve (256=P-256, 384=P-384, 521=P-521) | depends on account_key_algorithm | | account_key_length | d,**g** | Key-length for newly generated RSA account keys (in bits) or EC curve (256=P-256, 384=P-384, 521=P-521) | depends on account_key_algorithm |
| ttl_days | d,**g** | Renew certificate if it has less than this value validity left | 30 | | ttl_days | d,**g** | Renew certificate if it has less than this value validity left | 30 |
| validate_ocsp | d,**g** | Renew certificate if it's OCSP status is REVOKED. Allowed values for this key are: false, sha1, sha224, sha256, sha384, sha512 | sha1 (as mandated by RFC5019) |
| cert_dir | d,**g** | Directory containing all certificate related data (crt,key,csr) | {work_dir} | | cert_dir | d,**g** | Directory containing all certificate related data (crt,key,csr) | {work_dir} |
| key_algorithm | d,**g** | Key-algorithm for newly generated private keys (RSA, EC, ED25519, ED448) | RSA | | key_algorithm | d,**g** | Key-algorithm for newly generated private keys (RSA, ECC, ED25519, ED448) | RSA |
| key_length | d,**g** | Key-length for newly generated RSA private keys (in bits) or EC curve (256=P-256, 384=P-384, 521=P-521) | depends on key_algorithm | | key_length | d,**g** | Key-length for newly generated RSA private keys (in bits) or EC curve (256=P-256, 384=P-384, 521=P-521) | depends on key_algorithm |
| csr_static | **d**,g | Whether to re-use a static CSR or generate a new dynamic CSR | false | | csr_static | **d**,g | Whether to re-use a static CSR or generate a new dynamic CSR | false |
| csr_file | **d**,g | Path to store (and load) the certificate CSR file | {cert_dir}/{cert_id}.csr | | csr_file | **d**,g | Path to store (and load) the certificate CSR file | {cert_dir}/{cert_id}.csr |
@ -120,4 +121,4 @@ Please keep the following in mind when using this software:
* Create a dedicated user for acertmgr (e.g. acertmgr) * Create a dedicated user for acertmgr (e.g. acertmgr)
* Run a acertmgr as that user (add acertmgr to that users cron!) * Run a acertmgr as that user (add acertmgr to that users cron!)
* Access rights to read/write all files configured with the created user * Access rights to read/write all files configured with the created user
* Run any programs/scripts defined on cert update as the created user (might need work-arounds with sudo or wrapper scripts) * Run any programs/scripts defined on cert update as the created user (might need work-arounds with sudo or wrapper scripts)

View File

@ -6,17 +6,23 @@
# Copyright (c) Rudolf Mayerhofer, 2019. # Copyright (c) Rudolf Mayerhofer, 2019.
# available under the ISC license, see LICENSE # available under the ISC license, see LICENSE
import grp
import io import io
import os import os
import pwd
import stat import stat
import subprocess import subprocess
import sys
from acertmgr import configuration, tools from acertmgr import configuration, tools
from acertmgr.authority import authority from acertmgr.authority import authority
from acertmgr.modes import challenge_handler from acertmgr.modes import challenge_handler
from acertmgr.tools import log from acertmgr.tools import log, LOG_REPLACEMENTS
try:
import pwd
import grp
except ImportError:
# Warnings will be reported upon usage below
pass
# @brief fetch new certificate from letsencrypt # @brief fetch new certificate from letsencrypt
@ -90,18 +96,25 @@ def cert_put(settings):
# set owner and group # set owner and group
if 'user' in settings or 'group' in settings: if 'user' in settings or 'group' in settings:
try: if 'pwd' in sys.modules and 'grp' in sys.modules and hasattr(os, 'chown') and hasattr(os, 'geteuid') and \
uid = pwd.getpwnam(settings['user']).pw_uid if 'user' in settings else os.geteuid() hasattr(os, 'getegid'):
gid = grp.getgrnam(settings['group']).gr_gid if 'group' in settings else os.getegid() try:
os.chown(settings['path'], uid, gid) uid = pwd.getpwnam(settings['user']).pw_uid if 'user' in settings else os.geteuid()
except OSError as e: gid = grp.getgrnam(settings['group']).gr_gid if 'group' in settings else os.getegid()
log('Could not set certificate file ownership', e, warning=True) os.chown(settings['path'], uid, gid)
except OSError as e:
log('Could not set certificate file ownership', e, warning=True)
else:
log('File user and group handling unavailable on this platform', warning=True)
# set permissions # set permissions
if 'perm' in settings: if 'perm' in settings:
try: if hasattr(os, 'chmod'):
os.chmod(settings['path'], int(settings['perm'], 8)) try:
except OSError as e: os.chmod(settings['path'], int(settings['perm'], 8))
log('Could not set certificate file permissions', e, warning=True) except OSError as e:
log('Could not set certificate file permissions', e, warning=True)
else:
log('File permission handling unavailable on this platform', warning=True)
return settings['action'] return settings['action']
@ -116,7 +129,8 @@ def cert_revoke(cert, configs, fallback_authority, reason=None):
if not acmeconfig: if not acmeconfig:
acmeconfig = fallback_authority acmeconfig = fallback_authority
log("No matching authority found to revoke {}: {}, using globalconfig/defaults".format(tools.get_cert_cn(cert), log("No matching authority found to revoke {}: {}, using globalconfig/defaults".format(tools.get_cert_cn(cert),
tools.get_cert_domains(cert)), warning=True) tools.get_cert_domains(
cert)), warning=True)
acme = authority(acmeconfig) acme = authority(acmeconfig)
acme.register_account() acme.register_account()
acme.revoke_crt(cert, reason) acme.revoke_crt(cert, reason)
@ -125,6 +139,10 @@ def cert_revoke(cert, configs, fallback_authority, reason=None):
def main(): def main():
# load config # load config
runtimeconfig, domainconfigs = configuration.load() runtimeconfig, domainconfigs = configuration.load()
# register idna-mapped domains as LOG_REPLACEMENTS for better readability of log output
for domainconfig in domainconfigs:
LOG_REPLACEMENTS.update({k: "{} [{}]".format(k, v) for k, v in domainconfig['domainlist_idna_mapped'].items()})
# Start processing
if runtimeconfig.get('mode') == 'revoke': if runtimeconfig.get('mode') == 'revoke':
# Mode: revoke certificate # Mode: revoke certificate
log("Revoking {}".format(runtimeconfig['revoke'])) log("Revoking {}".format(runtimeconfig['revoke']))
@ -144,9 +162,21 @@ def main():
cert = None cert = None
if os.path.isfile(config['cert_file']): if os.path.isfile(config['cert_file']):
cert = tools.read_pem_file(config['cert_file']) cert = tools.read_pem_file(config['cert_file'])
if not cert or not tools.is_cert_valid(cert, config['ttl_days']) or ( validate_ocsp = str(config.get('validate_ocsp')).lower() != 'false'
'force_renew' in runtimeconfig and if validate_ocsp and cert and os.path.isfile(config['ca_file']):
all(d in config['domainlist'] for d in runtimeconfig['force_renew'])): try:
issuer = tools.read_pem_file(config['ca_file'])
except Exception as e1:
log("Failed to retrieve issuer from ca file: {}. Trying to download...".format(e1))
try:
issuer = tools.download_issuer_ca(cert)
except Exception as e2:
log("Failed to download issuer for cert file: {}. Cannot validate OCSP.".format(e2))
validate_ocsp = False
if not cert or ('force_renew' in runtimeconfig and all(
d in config['domainlist'] for d in runtimeconfig['force_renew'])) \
or not tools.is_cert_valid(cert, config['ttl_days']) \
or (validate_ocsp and not tools.is_ocsp_valid(cert, issuer, config['validate_ocsp'])):
cert_get(config) cert_get(config)
if str(config.get('cert_revoke_superseded')).lower() == 'true' and cert: if str(config.get('cert_revoke_superseded')).lower() == 'true' and cert:
superseded.add(cert) superseded.add(cert)

View File

@ -260,8 +260,8 @@ class ACMEAuthority(AbstractACMEAuthority):
if code >= 400: if code >= 400:
raise ValueError("Error downloading certificate chain: {0} {1}".format(code, certificate)) raise ValueError("Error downloading certificate chain: {0} {1}".format(code, certificate))
cert_dict = re.match((r'(?P<cert>-----BEGIN CERTIFICATE-----[^\-]+-----END CERTIFICATE-----)\n\n' cert_dict = re.match((r'(?P<cert>^-----BEGIN CERTIFICATE-----\n[^\-]+\n-----END CERTIFICATE-----)\n*'
r'(?P<ca>-----BEGIN CERTIFICATE-----[^\-]+-----END CERTIFICATE-----)?'), r'(?P<ca>-----BEGIN CERTIFICATE-----\n.+\n-----END CERTIFICATE-----)?$'),
certificate, re.DOTALL).groupdict() certificate, re.DOTALL).groupdict()
cert = tools.convert_pem_str_to_cert(cert_dict['cert']) cert = tools.convert_pem_str_to_cert(cert_dict['cert'])
if cert_dict['ca'] is None: if cert_dict['ca'] is None:

View File

@ -16,9 +16,10 @@ import os
from acertmgr.tools import idna_convert from acertmgr.tools import idna_convert
# Configuration defaults to use if not specified otherwise # Configuration defaults to use if not specified otherwise
DEFAULT_CONF_FILE = "/etc/acertmgr/acertmgr.conf"
DEFAULT_CONF_DIR = "/etc/acertmgr" DEFAULT_CONF_DIR = "/etc/acertmgr"
DEFAULT_CONF_FILENAME = "acertmgr.conf"
DEFAULT_TTL = 30 # days DEFAULT_TTL = 30 # days
DEFAULT_VALIDATE_OCSP = "sha1" # mandated by RFC5019
DEFAULT_API = "v2" DEFAULT_API = "v2"
DEFAULT_AUTHORITY = "https://acme-v02.api.letsencrypt.org" DEFAULT_AUTHORITY = "https://acme-v02.api.letsencrypt.org"
@ -89,9 +90,14 @@ def parse_config_entry(entry, globalconfig, runtimeconfig):
config['id'] = hashlib.md5(domains.encode('utf-8')).hexdigest() config['id'] = hashlib.md5(domains.encode('utf-8')).hexdigest()
# Convert unicode to IDNA domains # Convert unicode to IDNA domains
config['domaintranslation'] = idna_convert(config['domainlist']) config['domainlist_idna_mapped'] = {}
if len(config['domaintranslation']) > 0: for idx in range(0, len(config['domainlist'])):
config['domainlist'] = [x for x, _ in config['domaintranslation']] if any(ord(c) >= 128 for c in config['domainlist'][idx]):
domain_human = config['domainlist'][idx]
domain_idna = idna_convert(domain_human)
if domain_idna != domain_human:
config['domainlist'][idx] = domain_idna # Update domain with idna counterpart
config['domainlist_idna_mapped'][domain_idna] = domain_human # Store original domain for reference
# Action config defaults # Action config defaults
config['defaults'] = globalconfig.get('defaults', {}) config['defaults'] = globalconfig.get('defaults', {})
@ -106,6 +112,9 @@ def parse_config_entry(entry, globalconfig, runtimeconfig):
update_config_value(config, 'ttl_days', localconfig, globalconfig, DEFAULT_TTL) update_config_value(config, 'ttl_days', localconfig, globalconfig, DEFAULT_TTL)
config['ttl_days'] = int(config['ttl_days']) config['ttl_days'] = int(config['ttl_days'])
# Validate OCSP on certificate verification
update_config_value(config, 'validate_ocsp', localconfig, globalconfig, DEFAULT_VALIDATE_OCSP)
# Revoke old certificate with reason superseded after renewal # Revoke old certificate with reason superseded after renewal
update_config_value(config, 'cert_revoke_superseded', localconfig, globalconfig, "false") update_config_value(config, 'cert_revoke_superseded', localconfig, globalconfig, "false")
@ -115,6 +124,17 @@ def parse_config_entry(entry, globalconfig, runtimeconfig):
# Use a static cert request # Use a static cert request
update_config_value(config, 'csr_static', localconfig, globalconfig, "false") update_config_value(config, 'csr_static', localconfig, globalconfig, "false")
# SSL key algorithm (if key has to be (re-)generated)
update_config_value(config, 'key_algorithm', localconfig, globalconfig, None)
# Update config id if we have a key algorithm set to allow for
# multiple certs with different algorithms for the same set of domains
if config.get('key_algorithm', None):
config['id'] += "_" + config['key_algorithm'].lower()
# SSL key length (if key has to be (re-)generated, converted to int)
update_config_value(config, 'key_length', localconfig, globalconfig, None)
config['key_length'] = int(config['key_length']) if config['key_length'] else None
# SSL cert request location # SSL cert request location
update_config_value(config, 'csr_file', localconfig, globalconfig, update_config_value(config, 'csr_file', localconfig, globalconfig,
os.path.join(config['cert_dir'], "{}.csr".format(config['id']))) os.path.join(config['cert_dir'], "{}.csr".format(config['id'])))
@ -127,13 +147,6 @@ def parse_config_entry(entry, globalconfig, runtimeconfig):
update_config_value(config, 'key_file', localconfig, globalconfig, update_config_value(config, 'key_file', localconfig, globalconfig,
os.path.join(config['cert_dir'], "{}.key".format(config['id']))) os.path.join(config['cert_dir'], "{}.key".format(config['id'])))
# SSL key algorithm (if key has to be (re-)generated)
update_config_value(config, 'key_algorithm', localconfig, globalconfig, None)
# SSL key length (if key has to be (re-)generated, converted to int)
update_config_value(config, 'key_length', localconfig, globalconfig, None)
config['key_length'] = int(config['key_length']) if config['key_length'] else None
# SSL CA location / use static # SSL CA location / use static
update_config_value(config, 'ca_file', localconfig, globalconfig, update_config_value(config, 'ca_file', localconfig, globalconfig,
os.path.join(config['cert_dir'], "{}.ca".format(config['id']))) os.path.join(config['cert_dir'], "{}.ca".format(config['id'])))
@ -158,8 +171,8 @@ def parse_config_entry(entry, globalconfig, runtimeconfig):
cfg.update(genericfgs[0]) cfg.update(genericfgs[0])
# Update handler config with more specific values (use original names for translated unicode domains) # Update handler config with more specific values (use original names for translated unicode domains)
_domain = _domaintranslation_dict.get(domain, domain) specificcfgs = [x for x in handlerconfigs if
specificcfgs = [x for x in handlerconfigs if 'domain' in x and x['domain'] == _domain] 'domain' in x and x['domain'] == config['domainlist_idna_mapped'].get(domain, domain)]
if len(specificcfgs) > 0: if len(specificcfgs) > 0:
cfg.update(specificcfgs[0]) cfg.update(specificcfgs[0])
@ -173,11 +186,11 @@ def load():
runtimeconfig = dict() runtimeconfig = dict()
parser = argparse.ArgumentParser(description="acertmgr - Automated Certificate Manager using ACME/Let's Encrypt") parser = argparse.ArgumentParser(description="acertmgr - Automated Certificate Manager using ACME/Let's Encrypt")
parser.add_argument("-c", "--config-file", nargs="?", parser.add_argument("-c", "--config-file", nargs="?",
help="global configuration file (default='{}')".format(DEFAULT_CONF_FILE)) help="global configuration file (default='$config_dir/{}')".format(DEFAULT_CONF_FILENAME))
parser.add_argument("-d", "--config-dir", nargs="?", parser.add_argument("-d", "--config-dir", nargs="?",
help="domain configuration directory (default='{}')".format(DEFAULT_CONF_DIR)) help="domain configuration directory (default='{}')".format(DEFAULT_CONF_DIR))
parser.add_argument("-w", "--work-dir", nargs="?", parser.add_argument("-w", "--work-dir", nargs="?",
help="persistent work data directory (default=config_dir)") help="persistent work data directory (default='$config_dir')")
parser.add_argument("--authority-tos-agreement", "--tos-agreement", "--tos", nargs="?", parser.add_argument("--authority-tos-agreement", "--tos-agreement", "--tos", nargs="?",
help="Agree to the authorities Terms of Service (value required depends on authority)") help="Agree to the authorities Terms of Service (value required depends on authority)")
parser.add_argument("--force-renew", "--renew-now", nargs="?", parser.add_argument("--force-renew", "--renew-now", nargs="?",
@ -188,18 +201,18 @@ def load():
help="Provide a revoke reason, see https://tools.ietf.org/html/rfc5280#section-5.3.1") help="Provide a revoke reason, see https://tools.ietf.org/html/rfc5280#section-5.3.1")
args = parser.parse_args() args = parser.parse_args()
# Determine global configuration file
if args.config_file:
global_config_file = args.config_file
else:
global_config_file = DEFAULT_CONF_FILE
# Determine domain configuration directory # Determine domain configuration directory
if args.config_dir: if args.config_dir:
domain_config_dir = args.config_dir domain_config_dir = args.config_dir
else: else:
domain_config_dir = DEFAULT_CONF_DIR domain_config_dir = DEFAULT_CONF_DIR
# Determine global configuration file
if args.config_file:
global_config_file = args.config_file
else:
global_config_file = os.path.join(domain_config_dir, DEFAULT_CONF_FILENAME)
# Runtime configuration: Get from command-line options # Runtime configuration: Get from command-line options
# - work_dir # - work_dir
if args.work_dir: if args.work_dir:
@ -218,9 +231,9 @@ def load():
# - force-rewew # - force-rewew
if args.force_renew: if args.force_renew:
domaintranslation = idna_convert(args.force_renew.split(' ')) domaintranslation = [idna_convert(d) for d in args.force_renew.split(' ')]
if len(domaintranslation) > 0: if len(domaintranslation) > 0:
runtimeconfig['force_renew'] = [x for x, _ in domaintranslation] runtimeconfig['force_renew'] = domaintranslation
else: else:
runtimeconfig['force_renew'] = args.force_renew.split(' ') runtimeconfig['force_renew'] = args.force_renew.split(' ')
@ -251,15 +264,29 @@ def load():
os.path.abspath(domain_config_file) != os.path.abspath(global_config_file): os.path.abspath(domain_config_file) != os.path.abspath(global_config_file):
with io.open(domain_config_file) as config_fd: with io.open(domain_config_file) as config_fd:
try: try:
for entry in json.load(config_fd).items(): data = json.load(config_fd)
domainconfigs.append(parse_config_entry(entry, globalconfig, runtimeconfig))
except ValueError: except ValueError:
import yaml import yaml
config_fd.seek(0) config_fd.seek(0)
for entry in yaml.safe_load(config_fd).items(): data = yaml.safe_load(config_fd)
domainconfigs.append(parse_config_entry(entry, globalconfig, runtimeconfig)) if isinstance(data, list):
# Handle newer config in list format (allows for multiple entries with same domains)
entries = list()
for element in data:
entries += element.items()
else:
# Handle older config format with just one entry per same domain set
entries = data.items()
for entry in entries:
domainconfigs.append(parse_config_entry(entry, globalconfig, runtimeconfig))
# Define a fallback authority from global configuration / defaults # Define a fallback authority from global configuration / defaults
runtimeconfig['fallback_authority'] = parse_authority([], globalconfig, runtimeconfig) runtimeconfig['fallback_authority'] = parse_authority([], globalconfig, runtimeconfig)
return runtimeconfig, domainconfigs return runtimeconfig, domainconfigs
if __name__ == '__main__':
# Simple configuration load test and output
from pprint import pprint
pprint(load())

View File

@ -28,10 +28,20 @@ class HTTPServer6(HTTPServer):
class ChallengeHandler(HTTPChallengeHandler): class ChallengeHandler(HTTPChallengeHandler):
def __init__(self, config): def __init__(self, config):
HTTPChallengeHandler.__init__(self, config) HTTPChallengeHandler.__init__(self, config)
bind_address = config.get("bind_address", "") self.bind_address = config.get("bind_address", "")
port = int(config.get("port", 80)) self.port = int(config.get("port", 80))
self.challenges = {} # Initialize the challenge data dict self.challenges = {} # Initialize the challenge data dict
self.server_thread = None
self.server = None
def create_challenge(self, domain, thumbprint, token):
self.challenges[token] = "{0}.{1}".format(token, thumbprint)
def destroy_challenge(self, domain, thumbprint, token):
del self.challenges[token]
def start_challenge(self, domain, thumbprint, token):
_self = self _self = self
# Custom HTTP request handler # Custom HTTP request handler
@ -54,19 +64,11 @@ class ChallengeHandler(HTTPChallengeHandler):
self.end_headers() self.end_headers()
self.wfile.write(value) self.wfile.write(value)
self.server_thread = None
try: try:
self.server = HTTPServer6((bind_address, port), _HTTPRequestHandler) self.server = HTTPServer6((self.bind_address, self.port), _HTTPRequestHandler)
except socket.gaierror: except socket.gaierror:
self.server = HTTPServer((bind_address, port), _HTTPRequestHandler) self.server = HTTPServer((self.bind_address, self.port), _HTTPRequestHandler)
def create_challenge(self, domain, thumbprint, token):
self.challenges[token] = "{0}.{1}".format(token, thumbprint)
def destroy_challenge(self, domain, thumbprint, token):
del self.challenges[token]
def start_challenge(self, domain, thumbprint, token):
def _serve(): def _serve():
self.server.serve_forever() self.server.serve_forever()
@ -78,3 +80,6 @@ class ChallengeHandler(HTTPChallengeHandler):
if self.server_thread.is_alive(): if self.server_thread.is_alive():
self.server.shutdown() self.server.shutdown()
self.server_thread.join() self.server_thread.join()
self.server.server_close()
self.server = None
self.server_thread = None

View File

@ -10,6 +10,8 @@ import base64
import datetime import datetime
import io import io
import os import os
import re
import stat
import sys import sys
import traceback import traceback
@ -21,6 +23,11 @@ from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
from cryptography.utils import int_to_bytes from cryptography.utils import int_to_bytes
from cryptography.x509.oid import NameOID, ExtensionOID from cryptography.x509.oid import NameOID, ExtensionOID
try:
from cryptography.x509 import ocsp
except ImportError:
pass
try: try:
from cryptography.hazmat.primitives.asymmetric import ed25519, ed448 from cryptography.hazmat.primitives.asymmetric import ed25519, ed448
except ImportError: except ImportError:
@ -31,6 +38,8 @@ try:
except ImportError: except ImportError:
from urllib2 import urlopen, Request # Python 2 from urllib2 import urlopen, Request # Python 2
LOG_REPLACEMENTS = {}
class InvalidCertificateError(Exception): class InvalidCertificateError(Exception):
pass pass
@ -52,6 +61,9 @@ def log(msg, exc=None, error=False, warning=False):
prefix = "" prefix = ""
output = prefix + msg output = prefix + msg
for k, v in LOG_REPLACEMENTS.items():
output = output.replace(k, v)
if exc: if exc:
_, exc_value, _ = sys.exc_info() _, exc_value, _ = sys.exc_info()
if not getattr(exc, '__traceback__', None) and exc == exc_value: if not getattr(exc, '__traceback__', None) and exc == exc_value:
@ -132,7 +144,7 @@ def new_ssl_key(path=None, key_algo=None, key_size=None):
key_size=key_size, key_size=key_size,
backend=default_backend() backend=default_backend()
) )
elif key_algo.lower() == 'ec': elif key_algo.lower() == 'ec' or key_algo.lower() == 'ecc':
if not key_size or key_size == 256: if not key_size or key_size == 256:
key_curve = ec.SECP256R1 key_curve = ec.SECP256R1
elif key_size == 384: elif key_size == 384:
@ -159,10 +171,13 @@ def new_ssl_key(path=None, key_algo=None, key_size=None):
) )
with io.open(path, 'wb') as pem_out: with io.open(path, 'wb') as pem_out:
pem_out.write(pem) pem_out.write(pem)
try: if hasattr(os, 'chmod'):
os.chmod(path, int("0400", 8)) try:
except OSError: os.chmod(path, int("0400", 8))
log('Could not set file permissions on {0}!'.format(path), warning=True) except OSError:
log('Could not set file permissions on {0}!'.format(path), warning=True)
else:
log('Keyfile permission handling unavailable on this platform', warning=True)
return private_key return private_key
@ -183,13 +198,21 @@ def read_pem_file(path, key=False, csr=False):
# @brief write cert data to PEM formatted file # @brief write cert data to PEM formatted file
def write_pem_file(crt, path, perms=None): def write_pem_file(crt, path, perms=None):
if hasattr(os, 'chmod') and os.path.exists(path):
try:
os.chmod(path, os.stat(path).st_mode | stat.S_IWRITE)
except OSError:
log('Could not make file ({0}) writable'.format(path), warning=True)
with io.open(path, "w") as f: with io.open(path, "w") as f:
f.write(convert_cert_to_pem_str(crt)) f.write(convert_cert_to_pem_str(crt))
if perms: if perms:
try: if hasattr(os, 'chmod'):
os.chmod(path, perms) try:
except OSError: os.chmod(path, perms)
log('Could not set file permissions ({0}) on {1}!'.format(perms, path), warning=True) except OSError:
log('Could not set file permissions ({0}) on {1}!'.format(perms, path), warning=True)
else:
log('PEM-File permission handling unavailable on this platform', warning=True)
# @brief download the issuer ca for a given certificate # @brief download the issuer ca for a given certificate
@ -225,8 +248,7 @@ def get_cert_domains(cert):
if san_cert: if san_cert:
for d in san_cert.value: for d in san_cert.value:
domains.add(d.value) domains.add(d.value)
# Convert IDNA domain to correct representation and return the list return domains
return [x for x, _ in idna_convert(domains)]
# @brief determine certificate cn # @brief determine certificate cn
@ -240,15 +262,26 @@ def get_cert_valid_until(cert):
# @brief convert certificate to PEM format # @brief convert certificate to PEM format
# @param cert certificate object in pyopenssl format # @param cert certificate object or a list thereof
# @return the certificate in PEM format # @return the certificate in PEM format
def convert_cert_to_pem_str(cert): def convert_cert_to_pem_str(cert):
return cert.public_bytes(serialization.Encoding.PEM).decode('utf8') if not isinstance(cert, list):
cert = [cert]
result = list()
for data in cert:
result.append(data.public_bytes(serialization.Encoding.PEM).decode('utf8'))
return '\n'.join(result)
# @brief load a PEM certificate from str # @brief load a PEM certificate from str
# @return a certificate object or a list of objects if multiple are in the string
def convert_pem_str_to_cert(certdata): def convert_pem_str_to_cert(certdata):
return x509.load_pem_x509_certificate(certdata.encode('utf8'), default_backend()) certs = re.findall(r'(-----BEGIN CERTIFICATE-----\n[^\-]+\n-----END CERTIFICATE-----)',
certdata, re.DOTALL)
result = list()
for data in certs:
result.append(x509.load_pem_x509_certificate(data.encode('utf8'), default_backend()))
return result[0] if len(result) == 1 else result
# @brief serialize cert/csr to DER bytes # @brief serialize cert/csr to DER bytes
@ -356,23 +389,64 @@ def target_is_current(target, file):
return target_date >= crt_date return target_date >= crt_date
# @brief convert domain list to idna representation (if applicable # @brief convert domain to idna representation (if applicable
def idna_convert(domainlist): def idna_convert(domain):
if 'idna' in sys.modules and any(ord(c) >= 128 for c in ''.join(domainlist)): try:
domaintranslation = list() if any(ord(c) >= 128 for c in domain):
for domain in domainlist: # Translate IDNA domain name from a unicode domain (handle wildcards separately)
if any(ord(c) >= 128 for c in domain): if domain.startswith('*.'):
# Translate IDNA domain name from a unicode domain (handle wildcards separately) idna_domain = "*.{}".format(domain[2:].encode('idna').decode('ascii'))
if domain.startswith('*.'):
idna_domain = "*.{}".format(domain[2:].encode('idna').decode('ascii'))
else:
idna_domain = domain.encode('idna').decode('ascii')
result = idna_domain, domain
else: else:
result = domain, domain idna_domain = domain.encode('idna').decode('ascii')
domaintranslation.append(result) return idna_domain
return domaintranslation except Exception as e:
log("Unicode domain(s) found but IDNA names could not be translated due to error: {}".format(e), error=True)
return domain
# @brief validate the OCSP status for a given certificate by the given issuer
def is_ocsp_valid(cert, issuer, hash_algo):
if hash_algo == 'sha1':
algorithm = hashes.SHA1
elif hash_algo == 'sha224':
algorithm = hashes.SHA224
elif hash_algo == 'sha256':
algorithm = hashes.SHA256
elif hash_algo == 'sha385':
algorithm = hashes.SHA384
elif hash_algo == 'sha512':
algorithm = hashes.SHA512
else: else:
if any(ord(c) >= 128 for c in ''.join(domainlist)) and 'idna' not in sys.modules: log("Invalid hash algorithm '{}' used for OCSP validation. Validation ignored.".format(hash_algo), warning=True)
log("Unicode domain(s) found but IDNA names could not be translated due to missing idna module", error=True) return True
return [(x, x) for x in domainlist]
if isinstance(issuer, list):
issuer = issuer[0] # First certificate in the CA chain is the immediate issuer
try:
ocsp_urls = []
aia = cert.extensions.get_extension_for_oid(ExtensionOID.AUTHORITY_INFORMATION_ACCESS)
for data in aia.value:
if data.access_method == x509.OID_OCSP:
ocsp_urls.append(data.access_location.value)
# This is a bit of a hack due to validation problems within cryptography (TODO: Check if this is still true)
# Correct replacement: ocsprequest = ocsp.OCSPRequestBuilder().add_certificate(cert, issuer, algorithm).build()
ocsprequest = ocsp.OCSPRequestBuilder((cert, issuer, (algorithm)())).build()
ocsprequestdata = ocsprequest.public_bytes(serialization.Encoding.DER)
for ocsp_url in ocsp_urls:
response = get_url(ocsp_url,
ocsprequestdata,
{
'Accept': 'application/ocsp-response',
'Content-Type': 'application/ocsp-request',
})
ocspresponsedata = response.read()
ocspresponse = ocsp.load_der_ocsp_response(ocspresponsedata)
if ocspresponse.response_status == ocsp.OCSPResponseStatus.SUCCESSFUL \
and ocspresponse.certificate_status == ocsp.OCSPCertStatus.REVOKED:
return False
except Exception as e:
log("An exception occurred during OCSP validation (Validation will be ignored): {}".format(e), error=True)
return True

View File

@ -68,3 +68,12 @@ mail.example.com smtp.example.com webmail.example.net *.intra.example.com:
perm: '400' perm: '400'
format: crt,ca format: crt,ca
action: '/etc/init.d/postfix reload' action: '/etc/init.d/postfix reload'
# this will use a different authority for the following set of domains (buypass.com in this example)
buypass-example.com *.buypass-example.com:
- authority: 'https://api.buypass.com/acme' # Removed trailing /directory from buypass docs for API endpoint
mode: dns.nsupdate
nsupdate_keyname: buypass
nsupdate_keyvalue: Test1234512359==
nsupdate_keyalgorithm: HMAC-MD5.SIG-ALG.REG.INT

View File

@ -26,68 +26,62 @@ def determine_version():
return version return version
# Derive version from git # Derive version from git
try: try:
output = subprocess.check_output(['git', 'describe', '--tags', '--dirty'], cwd=dir_path) \ output = subprocess.check_output(["git", "describe", "--tags", "--dirty"], cwd=dir_path) \
.decode('utf-8').strip().split('-') .decode("utf-8").strip().split("-")
if len(output) == 1: if len(output) == 1:
return output[0] return output[0]
elif len(output) == 2: elif len(output) == 2:
return "{}.dev0".format(output[0]) return "{}.dev0".format(output[0])
else: else:
release = 'dev' if len(output) == 4 and output[3] == 'dirty' else '' release = "dev" if len(output) == 4 and output[3] == "dirty" else ""
return "{}.{}{}+{}".format(output[0], release, output[1], output[2]) return "{}.{}{}+{}".format(output[0], release, output[1], output[2])
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
try: try:
commit = subprocess.check_output(['git', 'rev-parse', 'HEAD']).decode('utf-8').strip() commit = subprocess.check_output(["git", "rev-parse", "HEAD"]).decode("utf-8").strip()
status = subprocess.check_output(['git', 'status', '-s']).decode('utf-8').strip() status = subprocess.check_output(["git", "status", "-s"]).decode("utf-8").strip()
return "{}.dev0+{}".format(version, commit) if len(status) > 0 else "{}+{}".format(version, commit) return "{}.dev0+{}".format(version, commit) if len(status) > 0 else "{}+{}".format(version, commit)
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
# finding the git version has utterly failed, use version.txt # finding the git version has utterly failed, use version.txt
return version return version
setup( extra_requirements = {
name="acertmgr", "dns": ["dnspython"],
version=determine_version(), "yaml": ["PyYAML"],
author="Markus Hauschild", "idna": ["idna"],
author_email="moepman@binary-kitchen.de", "ocsp-must-staple": ["cryptography>=2.1"],
description="An automated certificate manager using ACME/letsencrypt", "ocsp-validation": ["cryptography>=2.4"],
license="ISC", "ed25519": ["cryptography>=2.6"],
keywords="acme letsencrypt", }
url="https://github.com/moepman/acertmgr",
packages=find_packages(), if __name__ == "__main__":
long_description=read('README.md'), setup(
long_description_content_type="text/markdown", name="acertmgr",
classifiers=[ version=determine_version(),
"Development Status :: 4 - Beta", author="Markus Hauschild",
"Programming Language :: Python", author_email="moepman@binary-kitchen.de",
"Environment :: Console", description="An automated certificate manager using ACME/letsencrypt",
"Topic :: Security :: Cryptography", license="ISC",
"License :: OSI Approved :: ISC License", keywords="acme letsencrypt",
], url="https://github.com/moepman/acertmgr",
install_requires=[ packages=find_packages(),
"cryptography>=0.6", long_description=read("README.md"),
], long_description_content_type="text/markdown",
extras_require={ classifiers=[
"dns": [ "Development Status :: 5 - Production/Stable",
"dnspython", "Programming Language :: Python",
"Environment :: Console",
"Topic :: Security :: Cryptography",
"License :: OSI Approved :: ISC License (ISCL)",
], ],
"yaml": [ install_requires=[
"yaml", "cryptography>=0.6",
], ],
"idna": [ extras_require=extra_requirements,
"idna", entry_points={
], "console_scripts": [
"ocsp-must-staple": [ "acertmgr=acertmgr:main",
"cryptography>=2.1", ],
], },
"ed25519": [ data_files=[("readme", ["README.md"])],
"cryptography>=2.6", )
],
},
entry_points={
'console_scripts': [
'acertmgr=acertmgr:main',
],
},
data_files=[('readme', ['README.md'])]
)

View File

@ -1 +1 @@
0.9.8 1.0.5