Compare commits

...

13 Commits

Author SHA1 Message Date
Markus 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
Jan 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
7 changed files with 113 additions and 59 deletions

View File

@ -3,16 +3,16 @@ name: Build (and release)
on: [ push, pull_request ]
jobs:
build:
runs-on: ubuntu-18.04
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- 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
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
@ -22,7 +22,7 @@ jobs:
git fetch --tags -f
VER="$(python3 setup.py --version)"
echo "Version found: $VER"
echo "::set-output name=version::$VER"
echo "version=$VER" >> $GITHUB_OUTPUT
- name: Build python package using setuptools (source/wheel)
run: |
@ -36,12 +36,20 @@ jobs:
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 "::set-output name=recommends3::$(echo "python3-pkg-resources")"
# Find optional dependencies to suggest in deb package
echo "::set-output name=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}')"
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: 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 }}" bdist_deb
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: |
@ -85,9 +93,22 @@ jobs:
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: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && (!(contains(github.ref, 'rc') || contains(github.ref, 'pre')))
uses: pypa/gh-action-pypi-publish@master
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)
* cryptography>=0.6 (usually includes the optional idna module)
* cryptography>=0.6
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):
| Directive | Context | Description | Built-in Default |
| --- | --- | --- | --- |
| --- | --- |----------------------------------------------------------------------------------------------------------------------------------------------| --- |
| -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 |
| -w/--work-dir | **c** | working directory containing csr/certificates/keys/ca files | /etc/acertmgr |
@ -65,7 +65,7 @@ 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) | |
| 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 |
| 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_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 |
@ -74,7 +74,7 @@ By default the directory (work_dir) containing the working data (csr,certificate
| 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} |
| 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 |
| 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 |
@ -121,4 +121,4 @@ Please keep the following in mind when using this software:
* Create a dedicated user for acertmgr (e.g. acertmgr)
* Run a acertmgr as that user (add acertmgr to that users cron!)
* 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

@ -15,7 +15,7 @@ import sys
from acertmgr import configuration, tools
from acertmgr.authority import authority
from acertmgr.modes import challenge_handler
from acertmgr.tools import log
from acertmgr.tools import log, LOG_REPLACEMENTS
try:
import pwd
@ -139,6 +139,10 @@ def cert_revoke(cert, configs, fallback_authority, reason=None):
def main():
# load config
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':
# Mode: revoke certificate
log("Revoking {}".format(runtimeconfig['revoke']))

View File

@ -90,9 +90,14 @@ def parse_config_entry(entry, globalconfig, runtimeconfig):
config['id'] = hashlib.md5(domains.encode('utf-8')).hexdigest()
# Convert unicode to IDNA domains
config['domaintranslation'] = idna_convert(config['domainlist'])
if len(config['domaintranslation']) > 0:
config['domainlist'] = [x for x, _ in config['domaintranslation']]
config['domainlist_idna_mapped'] = {}
for idx in range(0, len(config['domainlist'])):
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
config['defaults'] = globalconfig.get('defaults', {})
@ -119,6 +124,17 @@ def parse_config_entry(entry, globalconfig, runtimeconfig):
# Use a static cert request
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
update_config_value(config, 'csr_file', localconfig, globalconfig,
os.path.join(config['cert_dir'], "{}.csr".format(config['id'])))
@ -131,13 +147,6 @@ def parse_config_entry(entry, globalconfig, runtimeconfig):
update_config_value(config, 'key_file', localconfig, globalconfig,
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
update_config_value(config, 'ca_file', localconfig, globalconfig,
os.path.join(config['cert_dir'], "{}.ca".format(config['id'])))
@ -162,8 +171,8 @@ def parse_config_entry(entry, globalconfig, runtimeconfig):
cfg.update(genericfgs[0])
# 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 'domain' in x and x['domain'] == _domain]
specificcfgs = [x for x in handlerconfigs if
'domain' in x and x['domain'] == config['domainlist_idna_mapped'].get(domain, domain)]
if len(specificcfgs) > 0:
cfg.update(specificcfgs[0])
@ -222,9 +231,9 @@ def load():
# - force-rewew
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:
runtimeconfig['force_renew'] = [x for x, _ in domaintranslation]
runtimeconfig['force_renew'] = domaintranslation
else:
runtimeconfig['force_renew'] = args.force_renew.split(' ')
@ -255,15 +264,29 @@ def load():
os.path.abspath(domain_config_file) != os.path.abspath(global_config_file):
with io.open(domain_config_file) as config_fd:
try:
for entry in json.load(config_fd).items():
domainconfigs.append(parse_config_entry(entry, globalconfig, runtimeconfig))
data = json.load(config_fd)
except ValueError:
import yaml
config_fd.seek(0)
for entry in yaml.safe_load(config_fd).items():
domainconfigs.append(parse_config_entry(entry, globalconfig, runtimeconfig))
data = yaml.safe_load(config_fd)
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
runtimeconfig['fallback_authority'] = parse_authority([], globalconfig, runtimeconfig)
return runtimeconfig, domainconfigs
if __name__ == '__main__':
# Simple configuration load test and output
from pprint import pprint
pprint(load())

View File

@ -38,6 +38,8 @@ try:
except ImportError:
from urllib2 import urlopen, Request # Python 2
LOG_REPLACEMENTS = {}
class InvalidCertificateError(Exception):
pass
@ -59,6 +61,9 @@ def log(msg, exc=None, error=False, warning=False):
prefix = ""
output = prefix + msg
for k, v in LOG_REPLACEMENTS.items():
output = output.replace(k, v)
if exc:
_, exc_value, _ = sys.exc_info()
if not getattr(exc, '__traceback__', None) and exc == exc_value:
@ -139,7 +144,7 @@ def new_ssl_key(path=None, key_algo=None, key_size=None):
key_size=key_size,
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:
key_curve = ec.SECP256R1
elif key_size == 384:
@ -243,8 +248,7 @@ def get_cert_domains(cert):
if san_cert:
for d in san_cert.value:
domains.add(d.value)
# Convert IDNA domain to correct representation and return the list
return [x for x, _ in idna_convert(domains)]
return domains
# @brief determine certificate cn
@ -385,26 +389,19 @@ def target_is_current(target, file):
return target_date >= crt_date
# @brief convert domain list to idna representation (if applicable
def idna_convert(domainlist):
if any(ord(c) >= 128 for c in ''.join(domainlist)):
try:
domaintranslation = list()
for domain in domainlist:
if any(ord(c) >= 128 for c in domain):
# Translate IDNA domain name from a unicode domain (handle wildcards separately)
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:
result = domain, domain
domaintranslation.append(result)
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 [(x, x) for x in domainlist]
# @brief convert domain to idna representation (if applicable
def idna_convert(domain):
try:
if any(ord(c) >= 128 for c in domain):
# Translate IDNA domain name from a unicode domain (handle wildcards separately)
if domain.startswith('*.'):
idna_domain = "*.{}".format(domain[2:].encode('idna').decode('ascii'))
else:
idna_domain = domain.encode('idna').decode('ascii')
return idna_domain
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
@ -435,7 +432,7 @@ def is_ocsp_valid(cert, issuer, hash_algo):
# 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()
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,

View File

@ -68,3 +68,12 @@ mail.example.com smtp.example.com webmail.example.net *.intra.example.com:
perm: '400'
format: crt,ca
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

@ -1 +1 @@
1.0.4
1.0.5