Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 12 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)

Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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);
```
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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`<br>`dns-persist-01`<br>`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)
Expand Down Expand Up @@ -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.



Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
@@ -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": [
Expand Down
30 changes: 29 additions & 1 deletion src/ACMECert.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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(
Expand Down
22 changes: 20 additions & 2 deletions src/ACMEv2.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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();
Expand All @@ -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(
Expand Down
Loading