Production-grade ACME protocol client library for automated SSL/TLS certificate management
Project description
ACMEOW
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 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
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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c9b29a915484cbb1993a396609adc9db8e763c3471e754a4e6a8e81881d8d47f
|
|
| MD5 |
cbf1c82e15bacac03dc2b7f7dcc98594
|
|
| BLAKE2b-256 |
41acb319706dcc69bcf52b97f087ac57a66e8d07056706a56d29a4cf36be1d36
|
Provenance
The following attestation bundles were made for acmeow-1.1.1.tar.gz:
Publisher:
python-publish.yml on miichoow/ACMEOW
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
acmeow-1.1.1.tar.gz -
Subject digest:
c9b29a915484cbb1993a396609adc9db8e763c3471e754a4e6a8e81881d8d47f - Sigstore transparency entry: 953510167
- Sigstore integration time:
-
Permalink:
miichoow/ACMEOW@5d48d34c020351a797e221026e02b1e302565326 -
Branch / Tag:
- Owner: https://github.com/miichoow
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
python-publish.yml@5d48d34c020351a797e221026e02b1e302565326 -
Trigger Event:
release
-
Statement type:
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
25cac63776470e0d679b46d5ae027d45d1e4f108a9230edaa9b23f01100d70d5
|
|
| MD5 |
24bced840822371228cd850c7f214822
|
|
| BLAKE2b-256 |
c41de9437d677416a1315aabcba8a04b766cd3f1ffe0037da65e57fb6899972a
|
Provenance
The following attestation bundles were made for acmeow-1.1.1-py3-none-any.whl:
Publisher:
python-publish.yml on miichoow/ACMEOW
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
acmeow-1.1.1-py3-none-any.whl -
Subject digest:
25cac63776470e0d679b46d5ae027d45d1e4f108a9230edaa9b23f01100d70d5 - Sigstore transparency entry: 953510171
- Sigstore integration time:
-
Permalink:
miichoow/ACMEOW@5d48d34c020351a797e221026e02b1e302565326 -
Branch / Tag:
- Owner: https://github.com/miichoow
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
python-publish.yml@5d48d34c020351a797e221026e02b1e302565326 -
Trigger Event:
release
-
Statement type: