ACME Client
Uma biblioteca completa de cliente ACME v2 em PHP para automatizar o gerenciamento de certificados SSL/TLS com Let's Encrypt, ZeroSSL e outras Autoridades Certificadoras compatíveis com ACME.
Idioma / 语言: English | 中文
Funcionalidades
- Suporte ao Protocolo ACME v2: Compatibilidade total com a especificação ACME v2
- Suporte a Múltiplas ACs: Funciona com Let's Encrypt, ZeroSSL e outros provedores ACME
- Gestão de Conta: Crie, armazene e gerencie contas ACME
- Operações de Certificado: Solicite, renove e revogue certificados SSL
- Validação de Domínio: Suporte para desafios HTTP-01 e DNS-01
- Suporte a ARI: Informações de Renovação Automática para um tempo ideal de renovação
- Tipos de Chave Flexíveis: Suporte para chaves RSA e ECC
- Log Abrangente: Registro embutido compatível com PSR-3
- Integração Fácil: Design de API simples e intuitivo
Requisitos
- PHP 8.2 ou superior
- Extensão OpenSSL
- Extensão cURL
- Extensão JSON
- Extensão mbstring
Instalação
Instale via Composer:
composer require alapi/acme-clientInício Rápido
1. Criar Chaves de Conta Local
Você tem duas maneiras de criar e gerenciar chaves de conta ACME:
Opção A: Usando chaves existentes com a classe Account
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);
Opção B: Usando AccountStorage para gerenciamento de chaves baseado em arquivos
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. Inicializar Cliente ACME
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. Registrar Conta ACME
Para Let's Encrypt (não é necessário EAB):
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";
}Para ZeroSSL (EAB obrigatório):
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. Solicitar Certificado
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. Validação Completa de Domínio
Após colocar os arquivos de desafio em seu servidor web:
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. Gerar e Enviar 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";
}
Uso Avançado
Desafio DNS-01
Para certificados curinga ou quando a validação HTTP não é possível:
// 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
);
Renovação de Certificado com 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";
}
Revogação de Certificado
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";
}Múltiplas Autoridades Certificadoras
// 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'
);
Configuração Personalizada do Cliente HTTP
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'
]
]);
Registro
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);
Configuração
Opções de Gerenciamento de Conta
Usando AccountStorage para gerenciamento baseado em arquivos:
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'
);
Usando a classe Account para chaves existentes:
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";
Tratamento de Erros
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";
}
Testando
Execute a suíte de testes:
composer testExecute a análise estática:
composer analyseCorrija o estilo do código:
composer cs-fixConsiderações de Segurança
- Chaves Privadas: Armazene as chaves privadas com segurança e permissões de arquivo apropriadas (600)
- Chaves de Conta: Mantenha as chaves de conta separadas das chaves de certificado
- Ambiente de Homologação: Use o ambiente de homologação para testes
- Limites de Taxa: Esteja atento aos limites de taxa da CA
- Validação: Sempre valide os desafios localmente antes de acionar a validação ACME
Contribuindo
- Faça um fork do repositório
- Crie um branch de funcionalidade
- Faça suas alterações
- Adicione testes para novas funcionalidades
- Execute a suíte de testes
- Envie um pull request
Licença
Este projeto está licenciado sob a Licença MIT - veja o arquivo LICENSE para mais detalhes.
Links
Suporte
Se você encontrar algum problema ou tiver dúvidas:
- Verifique a documentação
- Pesquise por issues existentes
- Crie uma nova issue se necessário