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

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

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:

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:

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.