Skip to main content

Production-grade ACME protocol client library for automated SSL/TLS certificate management

Project description

ACMEOW

Tests PyPI version Python versions License codecov Code style: ruff Documentation

A production-grade Python library for automated SSL/TLS certificate management using the ACME protocol (RFC 8555).

Documentation | PyPI | GitHub

Features

  • Full ACME Protocol Support: Complete RFC 8555 implementation including:
    • Account creation, update, key rollover, and deactivation
    • Certificate ordering, issuance, and revocation
    • DNS-01, HTTP-01, and TLS-ALPN-01 challenge validation
    • External CSR support for bring-your-own key workflows
  • Multiple Challenge Types: Supports DNS-01, HTTP-01, and TLS-ALPN-01 challenges
  • Flexible Challenge Handlers: Built-in handlers and callback-based custom handlers
  • Automatic Retry with Backoff: Configurable retry logic for transient failures
  • DNS Propagation Verification: Optional verification that DNS records are visible before challenge completion
  • Order Recovery: Resume interrupted certificate orders automatically
  • Preferred Chain Selection: Choose alternate certificate chains (e.g., for compatibility)
  • Thread-Safe: Safe for use in multi-threaded applications
  • Type Hints: Full type annotations for better IDE support
  • Modern Python: Requires Python 3.10+

Installation

pip install acmeow

Or install from source:

git clone https://github.com/miichoow/ACMEOW.git
cd ACMEOW
pip install -e .

For development:

pip install -e ".[dev]"

Quick Start

from pathlib import Path
from acmeow import AcmeClient, Identifier, KeyType, CallbackDnsHandler

# Create client
client = AcmeClient(
    server_url="https://acme-v02.api.letsencrypt.org/directory",
    email="admin@example.com",
    storage_path=Path("./acme_data"),
)

# Create account
client.create_account()

# Create order
order = client.create_order([Identifier.dns("example.com")])

# Define DNS record handlers (implement with your DNS provider)
def create_record(domain, name, value):
    # Create TXT record using your DNS provider API
    pass

def delete_record(domain, name):
    # Delete TXT record
    pass

# Complete challenges
handler = CallbackDnsHandler(create_record, delete_record, propagation_delay=60)
client.complete_challenges(handler)

# Finalize and get certificate
client.finalize_order(KeyType.EC256)
cert_pem, key_pem = client.get_certificate()

Challenge Handlers

DNS-01 Challenge (recommended for wildcards)

from acmeow import CallbackDnsHandler

def create_txt(domain: str, record_name: str, value: str) -> None:
    # record_name is "_acme-challenge.example.com"
    # value is the base64url SHA-256 hash to put in the TXT record
    your_dns_api.create_record(record_name, "TXT", value)

def delete_txt(domain: str, record_name: str) -> None:
    your_dns_api.delete_record(record_name, "TXT")

handler = CallbackDnsHandler(
    create_record=create_txt,
    delete_record=delete_txt,
    propagation_delay=120,  # Wait for DNS propagation
)

HTTP-01 Challenge (simpler setup, no wildcards)

from pathlib import Path
from acmeow import FileHttpHandler, ChallengeType

# Files written to {webroot}/.well-known/acme-challenge/
handler = FileHttpHandler(webroot=Path("/var/www/html"))

client.complete_challenges(handler, challenge_type=ChallengeType.HTTP)

Or with callbacks:

from acmeow import CallbackHttpHandler

def setup(domain: str, token: str, key_authorization: str) -> None:
    # Serve key_authorization at http://{domain}/.well-known/acme-challenge/{token}
    pass

def cleanup(domain: str, token: str) -> None:
    # Remove the challenge response
    pass

handler = CallbackHttpHandler(setup, cleanup)

TLS-ALPN-01 Challenge (TLS termination control)

TLS-ALPN-01 proves domain control by serving a validation certificate with the ACME identifier extension (RFC 8737).

from acmeow import CallbackTlsAlpnHandler, ChallengeType

def deploy_cert(domain: str, cert_pem: bytes, key_pem: bytes) -> None:
    # Configure your TLS server with the validation certificate
    your_tls_server.set_certificate(domain, cert_pem, key_pem)

def cleanup_cert(domain: str) -> None:
    your_tls_server.remove_certificate(domain)

handler = CallbackTlsAlpnHandler(deploy_cert, cleanup_cert)

client.complete_challenges(handler, challenge_type=ChallengeType.TLS_ALPN)

Or write certificates to files:

from pathlib import Path
from acmeow import FileTlsAlpnHandler, ChallengeType

handler = FileTlsAlpnHandler(
    cert_dir=Path("/etc/tls/acme"),
    cert_pattern="{domain}.alpn.crt",
    key_pattern="{domain}.alpn.key",
    reload_callback=lambda: subprocess.run(["nginx", "-s", "reload"]),
)

client.complete_challenges(handler, challenge_type=ChallengeType.TLS_ALPN)

Key Types

from acmeow import KeyType

# Available key types for certificate private keys:
KeyType.RSA2048  # RSA 2048-bit (minimum recommended)
KeyType.RSA3072  # RSA 3072-bit
KeyType.RSA4096  # RSA 4096-bit
KeyType.RSA8192  # RSA 8192-bit (slow)
KeyType.EC256    # ECDSA P-256 (recommended)
KeyType.EC384    # ECDSA P-384

Configuration Options

client = AcmeClient(
    server_url="https://acme-v02.api.letsencrypt.org/directory",
    email="admin@example.com",
    storage_path=Path("./acme_data"),
    verify_ssl=True,   # Verify SSL certificates (default: True)
    timeout=30,        # Request timeout in seconds (default: 30)
)

Retry Configuration

Configure automatic retry with exponential backoff for transient failures:

from acmeow import AcmeClient, RetryConfig

# Custom retry settings
retry_config = RetryConfig(
    max_retries=5,      # Maximum retry attempts (default: 5)
    initial_delay=1.0,  # Initial delay in seconds (default: 1.0)
    max_delay=60.0,     # Maximum delay between retries (default: 60.0)
    multiplier=2.0,     # Exponential backoff multiplier (default: 2.0)
    jitter=True,        # Add randomness to prevent thundering herd (default: True)
)

client = AcmeClient(
    server_url="https://acme-v02.api.letsencrypt.org/directory",
    email="admin@example.com",
    storage_path=Path("./acme_data"),
    retry_config=retry_config,
)

The client automatically retries on:

  • Rate limits (HTTP 429)
  • Server errors (HTTP 500, 502, 503, 504)
  • Connection errors and timeouts

Proxy Configuration

Use proxy for ACME HTTP client:

from acmeow import AcmeClient

# Custom retry settings
proxy_url = "socks5://proxy.example.com:1080"

client = AcmeClient(
    server_url="https://acme-v02.api.letsencrypt.org/directory",
    email="admin@example.com",
    storage_path=Path("./acme_data"),
    proxy_url=proxy_url,
)

DNS Propagation Verification

Verify DNS records are visible before completing DNS-01 challenges:

from acmeow import AcmeClient, DnsConfig

# Configure DNS verification
dns_config = DnsConfig(
    nameservers=["8.8.8.8", "1.1.1.1"],  # DNS servers to query
    timeout=5.0,                          # Query timeout in seconds
    retries=3,                            # Retries per server
    min_servers=1,                        # Minimum servers that must see the record
    require_all=False,                    # Require all servers to see the record
)

client = AcmeClient(...)
client.set_dns_config(dns_config)

# DNS propagation will be verified before notifying the ACME server
client.complete_challenges(handler, verify_dns=True, dns_timeout=300)

Order Recovery

Orders are automatically saved and can be resumed after interruption:

# Orders are automatically saved during creation
order = client.create_order([Identifier.dns("example.com")])

# If the process is interrupted, the order can be loaded later
client = AcmeClient(...)
client.create_account()  # Must create/load account first

# Load the saved order (returns None if no saved order exists)
order = client.load_order()
if order:
    print(f"Resumed order: {order.url} (status: {order.status})")

Preferred Certificate Chain

Select an alternate certificate chain when downloading the certificate:

# Get certificate with preferred chain (e.g., for older client compatibility)
cert_pem, key_pem = client.get_certificate(preferred_chain="ISRG Root X1")

# The preferred_chain parameter matches against the issuer CN in alternate chains
# If not found, the default chain is returned

External CSR

If you need to manage your own private key or use a CSR generated by an external tool (e.g., a hardware security module), pass the CSR directly to finalize_order:

from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import NameOID

# Generate key and CSR externally
my_key = ec.generate_private_key(ec.SECP256R1())
csr = (
    x509.CertificateSigningRequestBuilder()
    .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "example.com")]))
    .add_extension(
        x509.SubjectAlternativeName([x509.DNSName("example.com")]),
        critical=False,
    )
    .sign(my_key, hashes.SHA256())
)
csr_pem = csr.public_bytes(serialization.Encoding.PEM)

# Finalize order with external CSR (no key is generated or stored by ACMEOW)
client.finalize_order(csr=csr_pem)

# key_pem is None because the key was not managed by ACMEOW
cert_pem, key_pem = client.get_certificate()
assert key_pem is None

# Save the certificate alongside your own key
Path("certificate.pem").write_text(cert_pem)
Path("private_key.pem").write_bytes(
    my_key.private_bytes(
        serialization.Encoding.PEM,
        serialization.PrivateFormat.PKCS8,
        serialization.NoEncryption(),
    )
)

Both PEM (str or bytes) and DER (bytes) encoded CSRs are accepted. When a CSR is provided, the key_type and common_name parameters are ignored.

External Account Binding (EAB)

Some CAs require EAB to link ACME accounts to existing accounts:

client.set_external_account_binding(
    kid="your-key-id",
    hmac_key="your-base64url-hmac-key",
)
client.create_account()

ACME Servers

CA Production URL Staging URL
Let's Encrypt https://acme-v02.api.letsencrypt.org/directory https://acme-staging-v02.api.letsencrypt.org/directory
ZeroSSL https://acme.zerossl.com/v2/DV90 -
Buypass https://api.buypass.com/acme/directory https://api.test4.buypass.no/acme/directory

Storage Structure

acme_data/
├── accounts/
│   └── acme-v02.api.letsencrypt.org/
│       └── admin@example.com/
│           ├── account.json
│           └── keys/
│               └── admin@example.com.key
├── orders/
│   └── current_order.json    # Saved order for recovery
└── certificates/
    ├── example.com.crt
    └── example.com.key

Exception Handling

from acmeow import (
    AcmeError,              # Base exception
    AcmeServerError,        # Server returned an error
    AcmeAuthenticationError,# Account authentication failed
    AcmeAuthorizationError, # Challenge validation failed
    AcmeOrderError,         # Order creation/finalization failed
    AcmeCertificateError,   # Certificate download failed
    AcmeConfigurationError, # Invalid configuration
    AcmeNetworkError,       # Network communication failed
    AcmeTimeoutError,       # Operation timed out
    AcmeRateLimitError,     # Rate limit exceeded
    AcmeDnsError,           # DNS verification failed
)

try:
    client.complete_challenges(handler)
except AcmeRateLimitError as e:
    print(f"Rate limited, retry after {e.retry_after}s")
except AcmeDnsError as e:
    print(f"DNS verification failed for {e.domain}")
except AcmeAuthorizationError as e:
    print(f"Challenge failed for {e.domain}: {e.message}")
except AcmeError as e:
    print(f"ACME error: {e.message}")

Context Manager

with AcmeClient(...) as client:
    client.create_account()
    # ... operations
# Client is automatically closed

Account Management

Update Contact Email

# Update account contact information
client.update_account(email="new-email@example.com")

Account Key Rollover

# Roll over to a new account key (RFC 8555 Section 7.3.5)
client.key_rollover()

Deactivate Account

# Permanently deactivate the account (RFC 8555 Section 7.3.7)
# Warning: This cannot be undone!
client.deactivate_account()

Certificate Revocation

from acmeow import RevocationReason

# Revoke a certificate (RFC 8555 Section 7.6)
with open("certificate.pem") as f:
    cert_pem = f.read()

# Revoke without reason
client.revoke_certificate(cert_pem)

# Or specify a revocation reason
client.revoke_certificate(cert_pem, reason=RevocationReason.KEY_COMPROMISE)

Available revocation reasons:

  • RevocationReason.UNSPECIFIED - No specific reason
  • RevocationReason.KEY_COMPROMISE - Private key compromised
  • RevocationReason.CA_COMPROMISE - CA compromised
  • RevocationReason.AFFILIATION_CHANGED - Affiliation changed
  • RevocationReason.SUPERSEDED - Certificate superseded
  • RevocationReason.CESSATION_OF_OPERATION - Operations ceased
  • RevocationReason.CERTIFICATE_HOLD - Temporarily on hold
  • RevocationReason.REMOVE_FROM_CRL - Remove from CRL
  • RevocationReason.PRIVILEGE_WITHDRAWN - Privileges withdrawn
  • RevocationReason.AA_COMPROMISE - Attribute authority compromised

Development

Installation

# Clone the repository
git clone https://github.com/miichoow/ACMEOW.git
cd ACMEOW

# Create virtual environment
python -m venv .venv
source .venv/bin/activate  # Linux/macOS
# or
.venv\Scripts\activate  # Windows

# Install in development mode with all dependencies
pip install -e ".[dev,docs]"

Running Tests

# Run all tests
pytest

# Run with verbose output
pytest -v

# Run with coverage report
pytest --cov=acmeow --cov-report=html

# Run specific test file
pytest tests/test_client.py

# Run specific test class
pytest tests/test_client.py::TestAcmeClientInit

# Run specific test
pytest tests/test_client.py::TestAcmeClientInit::test_init_fetches_directory

Code Quality

# Run linter
ruff check src/acmeow/

# Run type checker
mypy src/acmeow/

# Format code (check only)
ruff format --check src/acmeow/

# Format code (apply changes)
ruff format src/acmeow/

Building Documentation

# Install docs dependencies
pip install -e ".[docs]"

# Build HTML documentation
cd docs
make html

# View documentation
open _build/html/index.html  # macOS
xdg-open _build/html/index.html  # Linux
start _build/html/index.html  # Windows

Examples

The examples/ directory contains complete working examples:

Example Description
dns_challenge.py DNS-01 challenge workflow for certificate issuance
http_challenge.py HTTP-01 challenge workflow for certificate issuance
account_management.py Account creation, updates, and key rollover
revoke_certificate.py Certificate revocation
deactivate_account.py Permanent account deactivation
eab_account.py External Account Binding for CAs like ZeroSSL

Run examples:

python examples/dns_challenge.py

License

Apache License 2.0

Contributing

Contributions are welcome! Please see CONTRIBUTING.md for guidelines.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

acmeow-1.1.1.tar.gz (121.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

acmeow-1.1.1-py3-none-any.whl (81.7 kB view details)

Uploaded Python 3

File details

Details for the file acmeow-1.1.1.tar.gz.

File metadata

  • Download URL: acmeow-1.1.1.tar.gz
  • Upload date:
  • Size: 121.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for acmeow-1.1.1.tar.gz
Algorithm Hash digest
SHA256 c9b29a915484cbb1993a396609adc9db8e763c3471e754a4e6a8e81881d8d47f
MD5 cbf1c82e15bacac03dc2b7f7dcc98594
BLAKE2b-256 41acb319706dcc69bcf52b97f087ac57a66e8d07056706a56d29a4cf36be1d36

See more details on using hashes here.

Provenance

The following attestation bundles were made for acmeow-1.1.1.tar.gz:

Publisher: python-publish.yml on miichoow/ACMEOW

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file acmeow-1.1.1-py3-none-any.whl.

File metadata

  • Download URL: acmeow-1.1.1-py3-none-any.whl
  • Upload date:
  • Size: 81.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for acmeow-1.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 25cac63776470e0d679b46d5ae027d45d1e4f108a9230edaa9b23f01100d70d5
MD5 24bced840822371228cd850c7f214822
BLAKE2b-256 c41de9437d677416a1315aabcba8a04b766cd3f1ffe0037da65e57fb6899972a

See more details on using hashes here.

Provenance

The following attestation bundles were made for acmeow-1.1.1-py3-none-any.whl:

Publisher: python-publish.yml on miichoow/ACMEOW

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page