diff --git a/README.md b/README.md index de1eb43..c842355 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# ACMECert v3.7.3 +# ACMECert v3.8.0 [![ACMECert Test](https://github.com/skoerfgen/ACMECert/actions/workflows/ACMECert.yaml/badge.svg)](https://github.com/skoerfgen/ACMECert/actions/workflows/ACMECert.yaml) [![CodeQL](https://github.com/skoerfgen/ACMECert/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/skoerfgen/ACMECert/actions/workflows/github-code-scanning/codeql) @@ -32,7 +32,7 @@ It is self contained and contains a set of functions allowing you to: It abstracts away the complexity of the ACME protocol to get a certificate (create order, fetch authorizations, compute challenge tokens, polling for status, generate CSR, finalize order, request certificate) into a single function [getCertificateChain](#acmecertgetcertificatechain) (or [getCertificateChains](#acmecertgetcertificatechains) to also get all alternate chains), -where you specify a set of domains you want to get a certificate for and which challenge type to use (all [challenge types](https://letsencrypt.org/docs/challenge-types/) are supported). +where you specify a set of domains you want to get a certificate for and which challenge type to use (Supported challenge types: `http-01`, `dns-01`, `tls-alpn-01`, `dns-account-01`, `dns-persist-01`). This function takes as third argument a user-defined callback function which gets invoked every time a challenge needs to be fulfilled. It is up to you to set/remove the challenge tokens: @@ -379,7 +379,7 @@ ini_set('log_errors',1); ini_set('error_log',dirname(__FILE__).'/ACMECert.log'); ``` -> To disable the default logging, you can use [`setLogger`](#acmecertsetlog), Exceptions are nevertheless thrown: +> To disable the default logging, you can use [`setLogger`](#acmecertsetlogger), Exceptions are nevertheless thrown: ```php $ac->setLogger(false); ``` @@ -671,9 +671,8 @@ public string ACMECert::getCertificateChain ( mixed $pem, array $domain_config, > 'test.example.net'=>array('challenge'=>'http-01','docroot'=>'/var/www/vhosts/test1.example.com'), > ); > ``` -> > Hint: Wildcard certificates (`*.example.com`) are only supported with the `dns-01` challenge type. > -> `challenge` is mandatory and has to be one of `http-01`, `dns-01` or `tls-alpn-01`. +> `challenge` is mandatory and has to be one of `http-01`, `dns-01`, `tls-alpn-01`, `dns-account-01` or `dns-persist-01`. > All other keys are optional and up to you to be used and are later available in the callback function as `$opts['config']` > (see the [http-01 example](#get-certificate-using-http-01-challenge) where `docroot` is used this way) @@ -709,9 +708,9 @@ public string ACMECert::getCertificateChain ( mixed $pem, array $domain_config, >> >> Challenge Type | `$opts['key']` | `$opts['value']` >> --- | --- | --- ->> http-01 | path + filename | file contents ->> dns-01 | TXT Resource Record Name | TXT Resource Record Value ->> tls-alpn-01 | unused | token used in the acmeIdentifier extension of the verification certificate; use [generateALPNCertificate](#acmecertgeneratealpncertificate) to generate the verification certificate from that token. (see the [tls-alpn-01 example](#get-certificate-using-all-http-01dns-01-and-tls-alpn-01-challenge-types-together)) +>> `http-01` | path + filename | file contents +>> `dns-01`
`dns-persist-01`
`dns-account-01` | TXT Resource Record Name | TXT Resource Record Value +>> `tls-alpn-01` | unused | token used in the acmeIdentifier extension of the verification certificate; use [generateALPNCertificate](#acmecertgeneratealpncertificate) to generate the verification certificate from that token. (see the [tls-alpn-01 example](#get-certificate-using-all-http-01dns-01-and-tls-alpn-01-challenge-types-together)) > **`settings`** (optional) @@ -756,15 +755,17 @@ public string ACMECert::getCertificateChain ( mixed $pem, array $domain_config, > >> **`group`** (boolean / default: `TRUE`) >> ->> When issuing certificates using the `dns-01` challenge for multiple domains that share the same `_acme-challenge` subdomain, such as: +>> When issuing certificates using the `dns-01` (or `dns-account-01`, `dns-persist-01`) challenge for multiple domains that share the same `_acme-challenge` subdomain, such as: >> - example.com >> - *.example.com (wildcard) >> >> two distinct TXT records must be created under the same DNS name `_acme-challenge.example.com` >> ->> By default, ACMECert groups these challenges together. This means all required TXT records for `_acme-challenge.example.com` are set simultaneously, and validation is triggered only after all records are in place. This approach prevents validation failures due to DNS caching delays. +>> By default, ACMECert groups these challenges together. This means the challenge callback for `_acme-challenge.example.com` is triggered twice (once for each domain), then the validation is triggered. Then the remove callback is triggered twice. This approach prevents validation failures due to DNS caching delays. >> ->> If set to `FALSE` challenges are handled independently. Each TXT record gets set and validated one at a time. +>> If set to `FALSE` challenges are handled independently. Each challenge callback is directly followed by the validation. +>> +>> For the `dns-persist-01` challenge type grouping is always enabled, because the DNS record of the wildcard `*.example.com` also is valid for `example.com`. In this case the challenge callback is triggered once, then both domains are validated. diff --git a/composer.json b/composer.json index 292d257..b1d0a36 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "skoerfgen/acmecert", - "version": "3.7.3", + "version": "3.8.0", "description": "PHP client library for Let's Encrypt and other ACME v2 - RFC 8555 compatible Certificate Authorities", "license": "MIT", "authors": [ diff --git a/src/ACMECert.php b/src/ACMECert.php index 13d8f8b..a06dc6b 100644 --- a/src/ACMECert.php +++ b/src/ACMECert.php @@ -205,7 +205,7 @@ public function getCertificateChain($pem,$domain_config,$callback,$settings=arra $groups[ $domain_config[$domain]['challenge']. '|'. - (($settings['group'])?ltrim($domain,'*.'):$domain) + (($settings['group'] || $domain_config[$domain]['challenge']==='dns-persist-01')?ltrim($domain,'*.'):$domain) ][$domain]=array($auth_url,$authorization); } @@ -236,6 +236,12 @@ public function getCertificateChain($pem,$domain_config,$callback,$settings=arra ); list($opts['key'],$opts['value'])=$challenge; + // prevent triggering the challenge callback for domain names already covered by wildcard + if ($type==='dns-persist-01' && isset($domain_config['*.'.$domain]) && $domain[0]!=='*'){ + $pending_challenges[]=array(null,$opts,$challenge_url,$auth_url); + continue; + } + $this->log('Triggering challenge callback for '.$domain.' using '.$type); $remove_cb=$callback($opts); @@ -622,6 +628,28 @@ private function parse_challenges($authorization,$type,&$url){ case 'tls-alpn-01': return array(null,hash('sha256',$this->keyAuthorization($challenge['token']))); break; + case 'dns-persist-01': + $arr=array( + reset($challenge['issuer-domain-names']), + 'accounturi='.$this->getAccountID() + ); + if (isset($authorization['wildcard']) && $authorization['wildcard']){ + $arr[]='policy=wildcard'; + } + return array( + '_validation-persist.'.$authorization['identifier']['value'], + implode('; ',$arr) + ); + break; + case 'dns-account-01': + return array( + '_'.$this->base32_encode(substr(hash('sha256',$this->getAccountID(),true),0,10)).'._acme-challenge.'.$authorization['identifier']['value'], + $this->base64url(hash('sha256',$this->keyAuthorization($challenge['token']),true)) + ); + break; + default: + throw new Exception('Challenge type: "'.$type.'" not yet supported by ACMECert, feel free to open a new issue: https://github.com/skoerfgen/ACMECert/issues'); + break; } } throw new Exception( diff --git a/src/ACMEv2.php b/src/ACMEv2.php index 0956016..0a59d73 100644 --- a/src/ACMEv2.php +++ b/src/ACMEv2.php @@ -280,6 +280,24 @@ protected function base64url_decode($data){ return base64_decode(strtr($data,'-_','+/')); } + protected function base32_encode($data){ + if ($data==='') return ''; + $alphabet='abcdefghijklmnopqrstuvwxyz234567'; + $binary=''; + foreach (str_split($data) as $char){ + $binary.=str_pad(decbin(ord($char)),8,'0',STR_PAD_LEFT); + } + $chunks=str_split($binary, 5); + $encoded=''; + foreach ($chunks as $chunk){ + if (strlen($chunk)<5) { + $chunk=str_pad($chunk,5,'0',STR_PAD_RIGHT); + } + $encoded.=$alphabet[bindec($chunk)]; + } + return str_pad($encoded,ceil(strlen($encoded)/8)*8,'=',STR_PAD_RIGHT); + } + private function json_decode($str){ $ret=json_decode($str,true); if ($ret===null) { @@ -310,7 +328,7 @@ protected function http_request($url,$data=null){ } $method=$data===false?'HEAD':($data===null?'GET':'POST'); - $user_agent='ACMECert v3.7.3 (+https://github.com/skoerfgen/ACMECert)'; + $user_agent='ACMECert v3.8.0 (+https://github.com/skoerfgen/ACMECert)'; $header=($data===null||$data===false)?array():array('Content-Type: application/jose+json'); if ($this->ch) { $headers=array(); @@ -332,7 +350,7 @@ protected function http_request($url,$data=null){ $took=microtime(true); $body=curl_exec($this->ch); $took=round(microtime(true)-$took,2).'s'; - if ($body===false) throw new Exception('HTTP Request Error: '.curl_error($this->ch)); + if ($body===false) throw new Exception('HTTP Request Error: ['.$url.'] '.curl_error($this->ch).' ('.curl_errno($this->ch).')'); }else{ $opts=array( 'http'=>array(