alapi / acme-client
Acme client library for ACME v2 written in PHP.
Requires
- php: ^8.2
- ext-curl: *
- ext-json: *
- ext-mbstring: *
- ext-openssl: *
- guzzlehttp/guzzle: ^7.0
- psr/http-client: ^1.0
- psr/http-message: ^1.0|^2.0
- psr/log: ^3.0
- spatie/dns: ^2.5
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.85
- pestphp/pest: ^3.8
- phpstan/phpstan: ^2.1
README
A comprehensive PHP ACME v2 client library for automating SSL/TLS certificate management with Let's Encrypt, ZeroSSL, and other ACME-compatible Certificate Authorities.
Features
- ACME v2 Protocol Support: Full compatibility with ACME v2 specification
- Multiple CA Support: Works with Let's Encrypt, ZeroSSL, and other ACME providers
- Account Management: Create, store, and manage ACME accounts
- Certificate Operations: Request, renew, and revoke SSL certificates
- Domain Validation: Support for HTTP-01 and DNS-01 challenges
- ARI Support: Automatic Renewal Information for optimal renewal timing
- Flexible Key Types: Support for RSA and ECC keys
- Comprehensive Logging: Built-in PSR-3 compatible logging
- Easy Integration: Simple and intuitive API design
Requirements
- PHP 8.2 or higher
- OpenSSL extension
- cURL extension
- JSON extension
- mbstring extension
Installation
Install via Composer:
composer require alapi/acme-client
Quick Start
1. Create Local Account Keys
You have two ways to create and manage ACME account keys:
Option A: Using existing keys with Account class
<?php require_once 'vendor/autoload.php'; use ALAPI\Acme\Accounts\Account; // Create account from existing private key string $privateKeyPem = '-----BEGIN PRIVATE KEY-----...-----END PRIVATE KEY-----'; $account = new Account($privateKeyPem); // Or create account with both private and public keys $publicKeyPem = '-----BEGIN PUBLIC KEY-----...-----END PUBLIC KEY-----'; $account = new Account($privateKeyPem, $publicKeyPem); // Or create account from private key only (public key will be extracted) $account = Account::fromPrivateKey($privateKeyPem);
Option B: Using AccountStorage for file-based key management
<?php require_once 'vendor/autoload.php'; use ALAPI\Acme\Utils\AccountStorage; // Create new ECC account and save to files (recommended) $account = AccountStorage::createAndSave( directory: 'storage', name: 'my-account', keyType: 'ECC', keySize: 'P-384' ); // Or create RSA account and save to files $rsaAccount = AccountStorage::createAndSave( directory: 'storage', name: 'my-rsa-account', keyType: 'RSA', keySize: 4096 ); echo "Account keys created and saved successfully!\n";
2. Initialize ACME Client
<?php require_once 'vendor/autoload.php'; use ALAPI\Acme\AcmeClient; use ALAPI\Acme\Accounts\Account; use ALAPI\Acme\Utils\AccountStorage; use ALAPI\Acme\Http\Clients\ClientFactory; // Option A: Load account from files $account = AccountStorage::loadFromFiles('storage', 'my-account'); // Option B: Create account from existing keys $privateKey = '-----BEGIN PRIVATE KEY-----...-----END PRIVATE KEY-----'; $account = new Account($privateKey); // Create HTTP client with optional proxy $httpClient = ClientFactory::create(timeout: 30, options: [ // 'proxy' => 'http://proxy.example.com:8080' ]); // Initialize client for Let's Encrypt production $acmeClient = new AcmeClient( staging: false, // Set to true for testing localAccount: $account, httpClient: $httpClient ); // Or use ZeroSSL $zeroSslClient = new AcmeClient( localAccount: $account, httpClient: $httpClient, baseUrl: 'https://acme.zerossl.com/v2/DV90/directory' );
3. Register ACME Account
For Let's Encrypt (no EAB required):
try { // Register account with Let's Encrypt $accountData = $acmeClient->account()->create( contacts: ['mailto:admin@example.com'] ); echo "Account registered successfully!\n"; echo "Account URL: " . $accountData->url . "\n"; } catch (Exception $e) { echo "Registration failed: " . $e->getMessage() . "\n"; }
For ZeroSSL (EAB required):
try { // Get EAB credentials from ZeroSSL dashboard $eabKid = 'your-eab-kid'; $eabHmacKey = 'your-eab-hmac-key'; $accountData = $zeroSslClient->account()->create( eabKid: $eabKid, eabHmacKey: $eabHmacKey, contacts: ['mailto:admin@example.com'] ); echo "ZeroSSL account registered successfully!\n"; } catch (Exception $e) { echo "Registration failed: " . $e->getMessage() . "\n"; }
4. Request Certificate
<?php use ALAPI\Acme\Enums\AuthorizationChallengeEnum; try { // Get account data $accountData = $acmeClient->account()->get(); // Create new order for domains $domains = ['example.com', 'www.example.com']; $order = $acmeClient->order()->new($accountData, $domains); echo "Order created: " . $order->url . "\n"; echo "Status: " . $order->status . "\n"; // Check domain validations $validations = $acmeClient->domainValidation()->status($order); foreach ($validations as $validation) { $domain = $validation->identifier['value']; echo "Domain: $domain - Status: " . $validation->status . "\n"; if ($validation->isPending()) { // Get validation data for HTTP-01 challenge $challenges = $acmeClient->domainValidation()->getValidationData( [$validation], AuthorizationChallengeEnum::HTTP ); foreach ($challenges as $challenge) { echo "HTTP Challenge for $domain:\n"; echo " File: " . $challenge['filename'] . "\n"; echo " Content: " . $challenge['content'] . "\n"; echo " Place it at: http://$domain/.well-known/acme-challenge/" . $challenge['filename'] . "\n\n"; } } } } catch (Exception $e) { echo "Error: " . $e->getMessage() . "\n"; }
5. Complete Domain Validation
After placing the challenge files on your web server:
try { // Trigger validation for each domain foreach ($validations as $validation) { if ($validation->isPending()) { $response = $acmeClient->domainValidation()->validate( $accountData, $validation, AuthorizationChallengeEnum::HTTP, localTest: true // Performs local validation first ); echo "Validation triggered for: " . $validation->identifier['value'] . "\n"; } } // Wait for validation to complete $maxAttempts = 10; $attempt = 0; do { sleep(5); $attempt++; // Check order status $currentOrder = $acmeClient->order()->get($accountData, $order->url); echo "Order status: " . $currentOrder->status . "\n"; if ($currentOrder->status === 'ready') { echo "All validations completed successfully!\n"; break; } if ($currentOrder->status === 'invalid') { echo "Order validation failed!\n"; break; } } while ($attempt < $maxAttempts); } catch (Exception $e) { echo "Validation error: " . $e->getMessage() . "\n"; }
6. Generate and Submit CSR
use ALAPI\Acme\Security\Cryptography\OpenSsl; try { if ($currentOrder->status === 'ready') { // Generate Certificate private key $certificatePrivateKey = OpenSsl::generatePrivateKey('RSA', 2048); // Generate Certificate Signing Request (CSR) using OpenSsl helper $csrString = OpenSsl::generateCsr($domains, $certificatePrivateKey); // Export private key for saving $privateKeyString = OpenSsl::openSslKeyToString($certificatePrivateKey); // Submit CSR to finalize order $finalizedOrder = $acmeClient->order()->finalize( $accountData, $currentOrder, $csrString ); echo "Order finalized successfully!\n"; echo "Certificate URL: " . $finalizedOrder->certificateUrl . "\n"; // Download certificate bundle $certificateBundle = $acmeClient->certificate()->get( $accountData, $finalizedOrder->certificateUrl ); // Save certificate and private key file_put_contents('certificate.pem', $certificateBundle->certificate); file_put_contents('fullchain.pem', $certificateBundle->fullchain); file_put_contents('private-key.pem', $privateKeyString); echo "Certificate saved to certificate.pem\n"; echo "Fullchain certificate saved to fullchain.pem\n"; echo "Private key saved to private-key.pem\n"; } } catch (Exception $e) { echo "Certificate generation error: " . $e->getMessage() . "\n"; }
Advanced Usage
DNS-01 Challenge
For wildcard certificates or when HTTP validation isn't possible:
// Get DNS challenge data $dnsChallenge = $acmeClient->domainValidation()->getValidationData( [$validation], AuthorizationChallengeEnum::DNS ); foreach ($dnsChallenge as $challenge) { echo "DNS Challenge for " . $challenge['domain'] . ":\n"; echo " Record Name: " . $challenge['domain'] . "\n"; echo " Record Type: TXT\n"; echo " Record Value: " . $challenge['digest'] . "\n\n"; } // After adding DNS records, trigger validation $response = $acmeClient->domainValidation()->validate( $accountData, $validation, AuthorizationChallengeEnum::DNS, localTest: true );
Certificate Renewal with ARI
use ALAPI\Acme\Management\RenewalManager; // Load existing certificate $certificatePem = file_get_contents('certificate.pem'); // Create renewal manager $renewalManager = $acmeClient->renewalManager(defaultRenewalDays: 30); // Check if renewal is needed if ($renewalManager->shouldRenew($certificatePem)) { echo "Certificate needs renewal\n"; // Get ARI information if supported if ($acmeClient->directory()->supportsARI()) { $renewalInfo = $acmeClient->renewalInfo()->getFromCertificate($certificatePem); echo "Suggested renewal window:\n"; echo " Start: " . $renewalInfo->suggestedWindow['start'] . "\n"; echo " End: " . $renewalInfo->suggestedWindow['end'] . "\n"; if ($renewalInfo->shouldRenewNow()) { echo "ARI recommends renewing now\n"; // Proceed with renewal... } } } else { echo "Certificate renewal not needed yet\n"; }
Certificate Revocation
try { // Load certificate to revoke $certificatePem = file_get_contents('certificate.pem'); // Revoke certificate $success = $acmeClient->certificate()->revoke( $certificatePem, reason: 1 // 0=unspecified, 1=keyCompromise, 2=cACompromise, 3=affiliationChanged, 4=superseded, 5=cessationOfOperation ); if ($success) { echo "Certificate revoked successfully\n"; } else { echo "Certificate revocation failed\n"; } } catch (Exception $e) { echo "Revocation error: " . $e->getMessage() . "\n"; }
Multiple Certificate Authorities
// Let's Encrypt $letsEncrypt = new AcmeClient( staging: false, localAccount: $account, httpClient: $httpClient ); // ZeroSSL $zeroSSL = new AcmeClient( localAccount: $account, httpClient: $httpClient, baseUrl: 'https://acme.zerossl.com/v2/DV90/directory' ); // Google Trust Services $googleCA = new AcmeClient( localAccount: $account, httpClient: $httpClient, baseUrl: 'https://dv.acme-v02.api.pki.goog/directory' );
Custom HTTP Client Configuration
use ALAPI\Acme\Http\Clients\ClientFactory; $httpClient = ClientFactory::create(30, [ 'proxy' => 'http://proxy.example.com:8080', 'verify' => true, // SSL verification 'timeout' => 30, 'connect_timeout' => 10, 'headers' => [ 'User-Agent' => 'MyApp ACME Client 1.0' ] ]);
Logging
use Monolog\Logger; use Monolog\Handler\StreamHandler; // Create logger $logger = new Logger('acme'); $logger->pushHandler(new StreamHandler('acme.log', Logger::INFO)); // Set logger on client $acmeClient->setLogger($logger);
Configuration
Account Management Options
Using AccountStorage for file-based management:
use ALAPI\Acme\Utils\AccountStorage; // Check if account files exist if (AccountStorage::exists('storage', 'my-account')) { $account = AccountStorage::loadFromFiles('storage', 'my-account'); } else { $account = AccountStorage::createAndSave('storage', 'my-account'); } // Load or create account automatically $account = AccountStorage::loadOrCreate( directory: 'storage', name: 'my-account', keyType: 'ECC', keySize: 'P-384' );
Using Account class for existing keys:
use ALAPI\Acme\Accounts\Account; // From existing private key $privateKey = file_get_contents('/path/to/private.key'); $account = new Account($privateKey); // With both private and public keys $privateKey = file_get_contents('/path/to/private.key'); $publicKey = file_get_contents('/path/to/public.key'); $account = new Account($privateKey, $publicKey); // Create new account with specific key type $account = Account::createECC('P-384'); // or 'P-256', 'P-384' $account = Account::createRSA(4096); // or 2048, 3072 // Get account information echo "Key Type: " . $account->getKeyType() . "\n"; echo "Key Size: " . $account->getKeySize() . "\n";
Error Handling
use ALAPI\Acme\Exceptions\AcmeException; use ALAPI\Acme\Exceptions\AcmeAccountException; use ALAPI\Acme\Exceptions\DomainValidationException; use ALAPI\Acme\Exceptions\AcmeCertificateException; try { // ACME operations here } catch (AcmeAccountException $e) { echo "Account error: " . $e->getMessage() . "\n"; echo "Detail: " . $e->getDetail() . "\n"; echo "Type: " . $e->getAcmeType() . "\n"; } catch (DomainValidationException $e) { echo "Validation error: " . $e->getMessage() . "\n"; } catch (AcmeCertificateException $e) { echo "Certificate error: " . $e->getMessage() . "\n"; } catch (AcmeException $e) { echo "ACME error: " . $e->getMessage() . "\n"; } catch (Exception $e) { echo "General error: " . $e->getMessage() . "\n"; }
Testing
Run the test suite:
composer test
Run static analysis:
composer analyse
Fix code style:
composer cs-fix
Security Considerations
- Private Keys: Store private keys securely with appropriate file permissions (600)
- Account Keys: Keep account keys separate from certificate keys
- Staging Environment: Use staging environment for testing
- Rate Limits: Be aware of CA rate limits
- Validation: Always validate challenges locally before triggering ACME validation
Contributing
- Fork the repository
- Create a feature branch
- Make your changes
- Add tests for new functionality
- Run the test suite
- Submit a pull request
License
This project is licensed under the MIT License - see the LICENSE file for details.
Links
Support
If you encounter any issues or have questions:
- Check the documentation
- Search existing issues
- Create a new issue if needed