A production-grade Python library for automated SSL/TLS certificate management using the ACME protocol (RFC 8555).
| Documentation | PyPI | GitHub |
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]"
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()
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
)
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 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)
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
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)
)
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:
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,
)
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)
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})")
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
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.
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()
| 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 |
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
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}")
with AcmeClient(...) as client:
client.create_account()
# ... operations
# Client is automatically closed
# Update account contact information
client.update_account(email="new-email@example.com")
# Roll over to a new account key (RFC 8555 Section 7.3.5)
client.key_rollover()
# Permanently deactivate the account (RFC 8555 Section 7.3.7)
# Warning: This cannot be undone!
client.deactivate_account()
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 reasonRevocationReason.KEY_COMPROMISE - Private key compromisedRevocationReason.CA_COMPROMISE - CA compromisedRevocationReason.AFFILIATION_CHANGED - Affiliation changedRevocationReason.SUPERSEDED - Certificate supersededRevocationReason.CESSATION_OF_OPERATION - Operations ceasedRevocationReason.CERTIFICATE_HOLD - Temporarily on holdRevocationReason.REMOVE_FROM_CRL - Remove from CRLRevocationReason.PRIVILEGE_WITHDRAWN - Privileges withdrawnRevocationReason.AA_COMPROMISE - Attribute authority compromised# 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]"
# 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
# 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/
# 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
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
Apache License 2.0
Contributions are welcome! Please see CONTRIBUTING.md for guidelines.