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
[](https://github.com/skoerfgen/ACMECert/actions/workflows/ACMECert.yaml)
[](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(