From 14a61d299b4122a1a610dd0be43414a94f5f4d26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Sat, 2 Apr 2022 20:04:32 +0200 Subject: [PATCH 01/42] added: External Account Binding support --- src/ACMECert.php | 30 +++++++++++++++++++++++++++++- src/ACMEv2.php | 24 +++++++++++++++--------- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/src/ACMECert.php b/src/ACMECert.php index 0ae9364..b1c3bba 100644 --- a/src/ACMECert.php +++ b/src/ACMECert.php @@ -37,12 +37,40 @@ class ACMECert extends ACMEv2 { // ACMECert - PHP client library for Let's Encry private $alternate_chains=array(); public function register($termsOfServiceAgreed=false,$contacts=array()){ + return $this->_register($termsOfServiceAgreed,$contacts); + } + + public function registerEAB($termsOfServiceAgreed=false,$eab_kid,$eab_hmac,$contacts=array()){ + if (!$this->resources) $this->readDirectory(); + + $protected=array( + 'alg'=>'HS256', + 'kid'=>$eab_kid, + 'url'=>$this->resources['newAccount'] + ); + $payload=$this->jwk_header['jwk']; + + $protected64=$this->base64url(json_encode($protected,JSON_UNESCAPED_SLASHES)); + $payload64=$this->base64url(json_encode($payload,JSON_UNESCAPED_SLASHES)); + + $signature=hash_hmac('sha256',$protected64.'.'.$payload64,$this->base64url_decode($eab_hmac),true); + + return $this->_register($termsOfServiceAgreed,$contacts,array( + 'externalAccountBinding'=>array( + 'protected'=>$protected64, + 'payload'=>$payload64, + 'signature'=>$this->base64url($signature) + ) + )); + } + + private function _register($termsOfServiceAgreed=false,$contacts=array(),$extra=array()){ $this->log('Registering account'); $ret=$this->request('newAccount',array( 'termsOfServiceAgreed'=>(bool)$termsOfServiceAgreed, 'contact'=>$this->make_contacts_array($contacts) - )); + )+$extra); $this->log($ret['code']==201?'Account registered':'Account already registered'); return $ret['body']; } diff --git a/src/ACMEv2.php b/src/ACMEv2.php index 842d35e..2c0e2da 100644 --- a/src/ACMEv2.php +++ b/src/ACMEv2.php @@ -133,20 +133,22 @@ protected function keyAuthorization($token){ return $token.'.'.$this->thumbprint; } + protected function readDirectory(){ + $this->log('Initializing ACME v2 environment: '.$this->directory); + $ret=$this->http_request($this->directory); // Read ACME Directory + if (!is_array($ret['body'])) { + throw new Exception('Failed to read directory: '.$this->directory); + } + $this->resources=$ret['body']; // store resources for later use + $this->log('Initialized'); + } + protected function request($type,$payload='',$retry=false){ if (!$this->jwk_header) { throw new Exception('use loadAccountKey to load an account key'); } - if (!$this->resources){ - $this->log('Initializing ACME v2 '.$this->mode.' environment'); - $ret=$this->http_request($this->directory); // Read ACME Directory - if (!is_array($ret['body'])) { - throw new Exception('Failed to read directory: '.$this->directory); - } - $this->resources=$ret['body']; // store resources for later use - $this->log('Initialized'); - } + if (!$this->resources) $this->readDirectory(); if (0===stripos($type,'http')) { $this->resources['_tmp']=$type; @@ -224,6 +226,10 @@ protected function base64url($data){ // RFC7515 - Appendix C return rtrim(strtr(base64_encode($data),'+/','-_'),'='); } + protected function base64url_decode($data){ + return base64_decode(strtr($data,'-_','+/')); + } + private function json_decode($str){ $ret=json_decode($str,true); if ($ret===null) { From 6f954e6cd96717fc585825c0a296d3ae5d6d62df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Sat, 2 Apr 2022 20:05:10 +0200 Subject: [PATCH 02/42] JSON_UNESCAPED_SLASHES --- src/ACMEv2.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ACMEv2.php b/src/ACMEv2.php index 2c0e2da..96f3e1e 100644 --- a/src/ACMEv2.php +++ b/src/ACMEv2.php @@ -192,8 +192,8 @@ protected function jws_encapsulate($type,$payload,$is_inner_jws=false){ // RFC75 $protected['url']=$this->resources[$type]; - $protected64=$this->base64url(json_encode($protected)); - $payload64=$this->base64url(is_string($payload)?$payload:json_encode($payload)); + $protected64=$this->base64url(json_encode($protected,JSON_UNESCAPED_SLASHES)); + $payload64=$this->base64url(is_string($payload)?$payload:json_encode($payload,JSON_UNESCAPED_SLASHES)); if (false===openssl_sign( $protected64.'.'.$payload64, From bc923c58044f3c8c56d34d752236c61153b54a45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Sat, 2 Apr 2022 20:12:46 +0200 Subject: [PATCH 03/42] allow specifying directory endpoint of ACME CA in constructor --- src/ACMEv2.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/ACMEv2.php b/src/ACMEv2.php index 96f3e1e..b40f290 100644 --- a/src/ACMEv2.php +++ b/src/ACMEv2.php @@ -37,10 +37,14 @@ class ACMEv2 { // Communication with Let's Encrypt via ACME v2 protocol $directories=array( 'live'=>'https://acme-v02.api.letsencrypt.org/directory', 'staging'=>'https://acme-staging-v02.api.letsencrypt.org/directory' - ),$ch=null,$bits,$sha_bits,$directory,$resources,$jwk_header,$kid_header,$account_key,$thumbprint,$nonce,$mode; + ),$ch=null,$bits,$sha_bits,$directory,$resources,$jwk_header,$kid_header,$account_key,$thumbprint,$nonce; public function __construct($live=true){ - $this->directory=$this->directories[$this->mode=($live?'live':'staging')]; + if (is_bool($live)) { + $this->directory=$this->directories[$live?'live':'staging']; + }else{ + $this->directory=$live; + } } public function __destruct(){ From a556c3640e55384552c371d202884d72b79186fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Sat, 2 Apr 2022 20:13:53 +0200 Subject: [PATCH 04/42] improved error handling --- src/ACMEv2.php | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/ACMEv2.php b/src/ACMEv2.php index b40f290..6b573d5 100644 --- a/src/ACMEv2.php +++ b/src/ACMEv2.php @@ -325,17 +325,13 @@ function($carry,$item)use(&$code){ switch($headers['content-type']){ case 'application/json': $body=$this->json_decode($body); + if (isset($body['error'])) { + $this->handleError($body['error']); + } break; case 'application/problem+json': $body=$this->json_decode($body); - throw new ACME_Exception($body['type'],$body['detail'], - array_map(function($subproblem){ - return new ACME_Exception( - $subproblem['type'], - '"'.$subproblem['identifier']['value'].'": '.$subproblem['detail'] - ); - },isset($body['subproblems'])?$body['subproblems']:array()) - ); + $this->handleError($body); break; } } @@ -352,4 +348,22 @@ function($carry,$item)use(&$code){ return $ret; } + + private function handleError($error){ + if ($error['type']==='compound' && isset($error['subproblems'][0])) { + $error['type']=$error['subproblems'][0]['type']; + $error['detail']=$error['subproblems'][0]['detail']; + } + + throw new ACME_Exception($error['type'],$error['detail'], + array_map(function($subproblem){ + return new ACME_Exception( + $subproblem['type'], + (isset($subproblem['identifier']['value'])? + '"'.$subproblem['identifier']['value'].'": ':'').$subproblem['detail'] + ); + },isset($error['subproblems'])?$error['subproblems']:array()) + ); + } + } From ac39e3bdf7c0c296837796afa273f43b5799926f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Sat, 2 Apr 2022 20:58:04 +0200 Subject: [PATCH 05/42] check ACME directory for presence of essential keys --- src/ACMEv2.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/ACMEv2.php b/src/ACMEv2.php index 6b573d5..ab2f910 100644 --- a/src/ACMEv2.php +++ b/src/ACMEv2.php @@ -140,7 +140,15 @@ protected function keyAuthorization($token){ protected function readDirectory(){ $this->log('Initializing ACME v2 environment: '.$this->directory); $ret=$this->http_request($this->directory); // Read ACME Directory - if (!is_array($ret['body'])) { + if ( + !is_array($ret['body']) || + !empty( + array_diff_key( + array_flip(array('newNonce','newAccount','newOrder')), + $ret['body'] + ) + ) + ){ throw new Exception('Failed to read directory: '.$this->directory); } $this->resources=$ret['body']; // store resources for later use From 74ff6f2c72ffc6dba048dc5a582005bba6db3275 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Sat, 2 Apr 2022 20:59:17 +0200 Subject: [PATCH 06/42] added: getTermsURL() to get URL to Terms Of Service --- src/ACMECert.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/ACMECert.php b/src/ACMECert.php index b1c3bba..8468630 100644 --- a/src/ACMECert.php +++ b/src/ACMECert.php @@ -103,6 +103,14 @@ public function deactivate($url){ return $ret['body']; } + public function getTermsURL(){ + if (!$this->resources) $this->readDirectory(); + if (!isset($this->resources['meta']['termsOfService'])){ + throw new Exception('Failed to get Terms Of Service URL'); + } + return $this->resources['meta']['termsOfService']; + } + public function keyChange($new_account_key_pem){ // account key roll-over $ac2=new ACMEv2(); $ac2->loadAccountKey($new_account_key_pem); From 553e2187d67210eca176ada86397bc4f74d6d2d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Sat, 2 Apr 2022 21:21:42 +0200 Subject: [PATCH 07/42] added: getCAAIdentities() to get array of CAA Identities --- src/ACMECert.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/ACMECert.php b/src/ACMECert.php index 8468630..6f4b039 100644 --- a/src/ACMECert.php +++ b/src/ACMECert.php @@ -111,6 +111,14 @@ public function getTermsURL(){ return $this->resources['meta']['termsOfService']; } + public function getCAAIdentities(){ + if (!$this->resources) $this->readDirectory(); + if (!isset($this->resources['meta']['caaIdentities'])){ + throw new Exception('Failed to get CAA Identities'); + } + return $this->resources['meta']['caaIdentities']; + } + public function keyChange($new_account_key_pem){ // account key roll-over $ac2=new ACMEv2(); $ac2->loadAccountKey($new_account_key_pem); From 58d1008a7503c1b96283350fdb16d001f7463a41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Sat, 2 Apr 2022 21:28:06 +0200 Subject: [PATCH 08/42] generateKey: cast key size to int --- src/ACMECert.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ACMECert.php b/src/ACMECert.php index 6f4b039..6ba53b4 100644 --- a/src/ACMECert.php +++ b/src/ACMECert.php @@ -369,7 +369,7 @@ private function generateKey($opts){ public function generateRSAKey($bits=2048){ return $this->generateKey(array( - 'private_key_bits'=>$bits, + 'private_key_bits'=>(int)$bits, 'private_key_type'=>OPENSSL_KEYTYPE_RSA )); } From ba3f6fc21343b7fdb73e9cc49d456ab23b59a589 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Sat, 2 Apr 2022 22:50:04 +0200 Subject: [PATCH 09/42] added: getSAN() to get Subject Alternative Names of given certificate --- src/ACMECert.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/ACMECert.php b/src/ACMECert.php index 6ba53b4..59b286c 100644 --- a/src/ACMECert.php +++ b/src/ACMECert.php @@ -394,6 +394,21 @@ public function parseCertificate($cert_pem){ return $ret; } + public function getSAN($pem){ + $ret=$this->parseCertificate($pem); + if (!isset($ret['extensions']['subjectAltName'])){ + throw new Exception('No Subject Alternative Name (SAN) found in certificate'); + } + $out=array(); + foreach(explode(',',$ret['extensions']['subjectAltName']) as $line){ + list($type,$name)=array_map('trim',explode(':',$line)); + if ($type==='DNS'){ + $out[]=$name; + } + } + return $out; + } + public function getRemainingDays($cert_pem){ $ret=$this->parseCertificate($cert_pem); return ($ret['validTo_time_t']-time())/86400; From 519f3b33485b7642e0397e3034f155e0bd94c1d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Sun, 3 Apr 2022 20:24:40 +0200 Subject: [PATCH 10/42] updated REAME.md --- README.md | 156 ++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 141 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index f9dcbba..bec9ec1 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # ACMECert -PHP client library for [Let's Encrypt](https://letsencrypt.org/) ([ACME v2 - RFC 8555](https://tools.ietf.org/html/rfc8555)) -Version: 3.1.2 +PHP client library for [Let's Encrypt](https://letsencrypt.org/) and other [ACME v2 - RFC 8555](https://tools.ietf.org/html/rfc8555) compatible Certificate Authorities. +Version: 3.2.0 ## Description @@ -79,14 +79,45 @@ use skoerfgen\ACMECert\ACMECert; ## Usage Examples -#### Choose Live or Staging Environment -> Live +#### Choose Certificate Authority (CA) +> Let's Encrypt - Live CA ```php -$ac=new ACMECert(); +$ac=new ACMECert(); // https://acme-v02.api.letsencrypt.org/directory is used. ``` -> Staging + +> Let's Encrypt - Staging CA +```php +$ac=new ACMECert(false); // https://acme-staging-v02.api.letsencrypt.org/directory is used. +``` + +> Buypass - Live CA +```php +$ac=new ACMECert('https://api.buypass.com/acme/directory'); +``` + +> Buypass - Staging CA +```php +$ac=new ACMECert('https://api.test4.buypass.no/acme/directory'); +``` + +> Google Trust Services - Live CA +```php +$ac=new ACMECert('https://dv.acme-v02.api.pki.goog/directory'); +``` + +> Google Trust Services - Staging CA +```php +$ac=new ACMECert('https://dv.acme-v02.test-api.pki.goog/directory'); +``` + +> ZeroSSL - Live CA ```php -$ac=new ACMECert(false); +$ac=new ACMECert('https://acme.zerossl.com/v2/DV90'); +``` + +> or any other ([ACME v2 - RFC 8555](https://tools.ietf.org/html/rfc8555)) compatible CA +```php +$ac=new ACMECert('INSERT_URL_TO_AMCE_CA_DIRECTORY_HERE'); ``` #### Generate RSA Private Key @@ -103,14 +134,19 @@ file_put_contents('account_key.pem',$key); ``` > Equivalent to: `openssl ecparam -name secp384r1 -genkey -noout -out account_key.pem` -#### Register Account Key with Let's Encrypt +#### Register Account Key with CA ```php $ac->loadAccountKey('file://'.'account_key.pem'); $ret=$ac->register(true,'info@example.com'); print_r($ret); ``` -> **WARNING: By passing **TRUE** as first parameter of the register function you agree to the terms of service of Let's Encrypt. See [Let’s Encrypt Subscriber Agreement](https://letsencrypt.org/repository/) for more information.** +#### Register Account Key with CA using External Account Binding +```php +$ac->loadAccountKey('file://'.'account_key.pem'); +$ret=$ac->registerEAB(true,'INSERT_EAB_KEY_ID_HERE','INSERT_EAB_HMAC_HERE','info@example.com'); +print_r($ret); +``` #### Get Account Information ```php @@ -309,12 +345,17 @@ try { Creates a new ACMECert instance. ```php -public ACMECert::__construct ( bool $live = TRUE ) +public ACMECert::__construct ( mixed $ca_url = TRUE ) ``` ###### Parameters -> **`live`** +> **`ca_url`** +> +> can be one of the following: > -> When **FALSE**, the ACME v2 [staging environment](https://acme-staging-v02.api.letsencrypt.org/) is used otherwise the [live environment](https://acme-v02.api.letsencrypt.org/). +> * a boolean: +> when `TRUE` the Let's Encrypt - Live CA (https://acme-v02.api.letsencrypt.org/directory) is used. +> when `FALSE` the Let's Encrypt - Staging CA (https://acme-staging-v02.api.letsencrypt.org/directory) is used. +> * a string containing the URL to an ACME CA directory endpoint. ###### Return Values > Returns a new ACMECert instance. @@ -381,16 +422,50 @@ public void ACMECert::loadAccountKey ( mixed $account_key_pem ) ### ACMECert::register -Associate the loaded account key with a Let's Encrypt account and optionally specify contacts. +Associate the loaded account key with the CA account and optionally specify contacts. ```php public array ACMECert::register ( bool $termsOfServiceAgreed = FALSE [, mixed $contacts = array() ] ) ``` ###### Parameters > **`termsOfServiceAgreed`** > -> **WARNING: By passing `TRUE`, you agree to the terms of service of Let's Encrypt. See [Let’s Encrypt Subscriber Agreement](https://letsencrypt.org/repository/) for more information.** +> By passing `TRUE`, you agree to the Terms Of Service of the selected CA. (Must be set to `TRUE` in order to successully register an account.) +> +> Hint: Use [getTermsURL()](#acmecertgettermsurl) to get the link to the current Terms Of Service. + + +> **`contacts`** +> +> can be one of the following: +> 1. A string containing an e-mail address +> 2. Array of e-mail adresses +###### Return Values +> Returns an array containing the account information. +###### Errors/Exceptions +> Throws an `ACME_Exception` if the server responded with an error message or an `Exception` if an other registration error occured. + +--- + +### ACMECert::registerEAB + +Associate the loaded account key with the CA account using External Account Binding (EAB) credentials and optionally specify contacts. +```php +public array ACMECert::registerEAB ( bool $termsOfServiceAgreed = FALSE, string $eab_kid, string $eab_hmac [, mixed $contacts = array() ] ) +``` +###### Parameters +> **`termsOfServiceAgreed`** +> +> By passing `TRUE`, you agree to the Terms Of Service of the selected CA. (Must be set to `TRUE` in order to successully register an account.) +> +> Hint: Use [getTermsURL()](#acmecertgettermsurl) to get the link to the current Terms Of Service. + +> **`eab_kid`** +> +> a string specifying the `EAB Key Identifier` + +> **`eab_hmac`** > -> Must be set to **TRUE** in order to successully register an account. +> a string specifying the `EAB HMAC Key` > **`contacts`** > @@ -577,6 +652,9 @@ public string ACMECert::getCertificateChain ( mixed $pem, array $domain_config, Get all (default and alternate) certificate-chains. This function takes the same arguments as the [getCertificateChain](#acmecertgetcertificatechain) function above, but it returns an array of certificate chains instead of a single chain. +```php +public string ACMECert::getCertificateChains ( mixed $pem, array $domain_config, callable $callback, bool $authz_reuse = TRUE ) +``` ###### Return Values > Returns an array of PEM encoded certificate chains. @@ -716,6 +794,54 @@ public array ACMECert::splitChain ( string $pem ) --- +### ACMECert::getCAAIdentities + +Get a list of all CAA Identities for the selected CA. (Useful for setting up CAA DNS Records) +```php +public array ACMECert::getCAAIdentities() +``` +###### Return Values +> Returns an array containing all CAA Identities for the selected CA. +###### Errors/Exceptions +> Throws an `ACME_Exception` if the server responded with an error message or an `Exception` if an other error occured getting the account information. + +--- + +### ACMECert::getSAN + +Get all Subject Alternative Names of given certificate. +```php +public array ACMECert::getSAN( mixed $pem ) +``` + +###### Parameters +> **`pem`** +> +> can be one of the following: +> * a string beginning with `file://` containing the filename to read a PEM encoded certificate or certificate-chain from. +> * a string containing the content of a certificate or certificate-chain, PEM encoded, may start with `-----BEGIN CERTIFICATE-----` + + +###### Return Values +> Returns an array containing all Subject Alternative Names of given certificate. +###### Errors/Exceptions +> Throws an `ACME_Exception` if the server responded with an error message or an `Exception` if an other error occured getting the account information. + +--- + +### ACMECert::getTermsURL + +Get URL to Terms Of Service for the selected CA. +```php +public array ACMECert::getTermsURL() +``` +###### Return Values +> Returns a string containing a URL to the Terms Of Service for the selected CA. +###### Errors/Exceptions +> Throws an `ACME_Exception` if the server responded with an error message or an `Exception` if an other error occured getting the account information. + +--- + > MIT License > > Copyright (c) 2018 Stefan Körfgen From 60ccdd924c3e1f4036df58c2c0f3a645d2531515 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Sun, 3 Apr 2022 20:34:34 +0200 Subject: [PATCH 11/42] updated version number --- src/ACMEv2.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ACMEv2.php b/src/ACMEv2.php index ab2f910..df11bc4 100644 --- a/src/ACMEv2.php +++ b/src/ACMEv2.php @@ -262,7 +262,7 @@ private function http_request($url,$data=null){ } } $method=$data===false?'HEAD':($data===null?'GET':'POST'); - $user_agent='ACMECert v3.1.2 (+https://github.com/skoerfgen/ACMECert)'; + $user_agent='ACMECert v3.2.0 (+https://github.com/skoerfgen/ACMECert)'; $header=($data===null||$data===false)?array():array('Content-Type: application/jose+json'); if ($this->ch) { $headers=array(); From 4000330e258f055ae49d3c771a297809b54a77c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Sun, 3 Apr 2022 21:01:15 +0200 Subject: [PATCH 12/42] README.md: improved CA section --- README.md | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index bec9ec1..7dad8f3 100644 --- a/README.md +++ b/README.md @@ -77,45 +77,49 @@ require 'vendor/autoload.php'; use skoerfgen\ACMECert\ACMECert; ``` -## Usage Examples +## Usage / Examples #### Choose Certificate Authority (CA) -> Let's Encrypt - Live CA +##### [Let's Encrypt](https://letsencrypt.org/) +> Live CA - https://acme-v02.api.letsencrypt.org/directory ```php -$ac=new ACMECert(); // https://acme-v02.api.letsencrypt.org/directory is used. +$ac=new ACMECert(); ``` -> Let's Encrypt - Staging CA +> Staging CA - https://acme-staging-v02.api.letsencrypt.org/directory ```php -$ac=new ACMECert(false); // https://acme-staging-v02.api.letsencrypt.org/directory is used. +$ac=new ACMECert(false); ``` -> Buypass - Live CA +##### [Buypass](https://buypass.com/) +> Live CA ```php $ac=new ACMECert('https://api.buypass.com/acme/directory'); ``` -> Buypass - Staging CA +> Staging CA ```php $ac=new ACMECert('https://api.test4.buypass.no/acme/directory'); ``` -> Google Trust Services - Live CA +##### [Google Trust Services](https://pki.goog/) +> Live CA ```php $ac=new ACMECert('https://dv.acme-v02.api.pki.goog/directory'); ``` -> Google Trust Services - Staging CA +> Staging CA ```php $ac=new ACMECert('https://dv.acme-v02.test-api.pki.goog/directory'); ``` -> ZeroSSL - Live CA +##### [ZeroSSL](https://zerossl.com/) +> Live CA ```php $ac=new ACMECert('https://acme.zerossl.com/v2/DV90'); ``` -> or any other ([ACME v2 - RFC 8555](https://tools.ietf.org/html/rfc8555)) compatible CA +##### or any other ([ACME v2 - RFC 8555](https://tools.ietf.org/html/rfc8555)) compatible CA ```php $ac=new ACMECert('INSERT_URL_TO_AMCE_CA_DIRECTORY_HERE'); ``` From 8f69eb4da2fdedbfa57a29972646dfffd6d097dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Sun, 3 Apr 2022 21:03:25 +0200 Subject: [PATCH 13/42] updated composer.json version number --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index b04452b..204bc4a 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "skoerfgen/acmecert", - "version": "3.1.2", + "version": "3.2.0", "description": "PHP client library for Let's Encrypt (ACME v2 - RFC 8555)", "license": "MIT", "authors": [ From fa4ffcba295f24cbbbbea0fdbf7ff1c466e7b6a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Mon, 4 Apr 2022 22:37:54 +0200 Subject: [PATCH 14/42] on non 2xx response: fall through to problem+json case --- src/ACMEv2.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/ACMEv2.php b/src/ACMEv2.php index df11bc4..163506f 100644 --- a/src/ACMEv2.php +++ b/src/ACMEv2.php @@ -332,11 +332,13 @@ function($carry,$item)use(&$code){ if (!empty($headers['content-type'])){ switch($headers['content-type']){ case 'application/json': - $body=$this->json_decode($body); - if (isset($body['error'])) { - $this->handleError($body['error']); + if ($code[0]=='2'){ // on non 2xx response: fall through to problem+json case + $body=$this->json_decode($body); + if (isset($body['error'])) { + $this->handleError($body['error']); + } + break; } - break; case 'application/problem+json': $body=$this->json_decode($body); $this->handleError($body); From 770f19732b53113e97ca622989f35ed10b024997 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Mon, 4 Apr 2022 22:43:21 +0200 Subject: [PATCH 15/42] parse received top issuer certificate / removed content-type check --- src/ACMECert.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ACMECert.php b/src/ACMECert.php index 59b286c..3da7437 100644 --- a/src/ACMECert.php +++ b/src/ACMECert.php @@ -480,9 +480,9 @@ private function poll($initial,$type,&$ret){ private function request_certificate($ret,$alternate=false){ $this->log('Requesting '.($alternate?'alternate':'default').' certificate-chain'); $ret=$this->request($ret['certificate'],''); - if ($ret['headers']['content-type']!=='application/pem-certificate-chain'){ - throw new Exception('Unexpected content-type: '.$ret['headers']['content-type']); - } + + $isser_cn=$this->getTopIssuerCN($ret['body']); + if (!$alternate) { if (isset($ret['headers']['link']['alternate'])){ $this->alternate_chains=$ret['headers']['link']['alternate']; @@ -490,7 +490,7 @@ private function request_certificate($ret,$alternate=false){ $this->alternate_chains=array(); } } - $this->log('Certificate-chain retrieved'); + $this->log('Certificate-chain retrieved: '.$isser_cn); return $ret['body']; } From 59fb3430934b23f33c2db3b23f3957c064148152 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Mon, 4 Apr 2022 22:51:58 +0200 Subject: [PATCH 16/42] added: SSL.com CA --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 7dad8f3..1b683ca 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,12 @@ $ac=new ACMECert('https://dv.acme-v02.api.pki.goog/directory'); $ac=new ACMECert('https://dv.acme-v02.test-api.pki.goog/directory'); ``` +##### [SSL.com](https://www.ssl.com/) +> Live CA +```php +$ac=new ACMECert('https://acme.ssl.com/sslcom-dv-rsa'); +``` + ##### [ZeroSSL](https://zerossl.com/) > Live CA ```php From 631cdd5024bf4c40b095c21823e63e038263729b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Mon, 4 Apr 2022 23:21:12 +0200 Subject: [PATCH 17/42] improved download section --- README.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1b683ca..012dfa5 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,19 @@ if it fails or an [ACME_Exception](#acme_exception) if the ACME-Server reponded ## Require ACMECert -manual installation: +manual download: https://github.com/skoerfgen/ACMECert/archive/master.zip + +usage: + +```php +require 'ACMECert.php'; + +use skoerfgen\ACMECert\ACMECert; +``` + +--- + +or download it using [git](https://git-scm.com/): ``` git clone https://github.com/skoerfgen/ACMECert @@ -64,9 +76,9 @@ use skoerfgen\ACMECert\ACMECert; --- -or install it using [composer](https://getcomposer.org): +or download it using [composer](https://getcomposer.org): ``` - composer require skoerfgen/acmecert +composer require skoerfgen/acmecert ``` usage: From 7fb01e24533367a2f78db5ff52e90a44cf6ae1ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Tue, 5 Apr 2022 01:09:47 +0200 Subject: [PATCH 18/42] corrected description of some methods --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 012dfa5..bab477e 100644 --- a/README.md +++ b/README.md @@ -825,7 +825,7 @@ public array ACMECert::getCAAIdentities() ###### Return Values > Returns an array containing all CAA Identities for the selected CA. ###### Errors/Exceptions -> Throws an `ACME_Exception` if the server responded with an error message or an `Exception` if an other error occured getting the account information. +> Throws an `ACME_Exception` if the server responded with an error message or an `Exception` if an other error occured getting the CAA Identities. --- @@ -847,7 +847,7 @@ public array ACMECert::getSAN( mixed $pem ) ###### Return Values > Returns an array containing all Subject Alternative Names of given certificate. ###### Errors/Exceptions -> Throws an `ACME_Exception` if the server responded with an error message or an `Exception` if an other error occured getting the account information. +> Throws an `Exception` if an error occured getting the Subject Alternative Names. --- @@ -860,7 +860,7 @@ public array ACMECert::getTermsURL() ###### Return Values > Returns a string containing a URL to the Terms Of Service for the selected CA. ###### Errors/Exceptions -> Throws an `ACME_Exception` if the server responded with an error message or an `Exception` if an other error occured getting the account information. +> Throws an `ACME_Exception` if the server responded with an error message or an `Exception` if an other error occured getting the Terms Of Service. --- From 70c36ebc6d5b5b58911cf8b93ccea51671215ea5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Tue, 5 Apr 2022 01:18:58 +0200 Subject: [PATCH 19/42] description updated --- README.md | 2 +- composer.json | 2 +- src/ACMECert.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index bab477e..48006bd 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ACMECert -PHP client library for [Let's Encrypt](https://letsencrypt.org/) and other [ACME v2 - RFC 8555](https://tools.ietf.org/html/rfc8555) compatible Certificate Authorities. +PHP client library for [Let's Encrypt](https://letsencrypt.org/) and other [ACME v2 - RFC 8555](https://tools.ietf.org/html/rfc8555) compatible Certificate Authorities. Version: 3.2.0 ## Description diff --git a/composer.json b/composer.json index 204bc4a..574125b 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "skoerfgen/acmecert", "version": "3.2.0", - "description": "PHP client library for Let's Encrypt (ACME v2 - RFC 8555)", + "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 3da7437..3bd787d 100644 --- a/src/ACMECert.php +++ b/src/ACMECert.php @@ -33,7 +33,7 @@ use Exception; use stdClass; -class ACMECert extends ACMEv2 { // ACMECert - PHP client library for Let's Encrypt (ACME v2) +class ACMECert extends ACMEv2 { private $alternate_chains=array(); public function register($termsOfServiceAgreed=false,$contacts=array()){ From 53da3e5ea58d7a059130509a66fb856dec43ceab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Tue, 5 Apr 2022 18:14:07 +0200 Subject: [PATCH 20/42] log ACME_Exceptions / preserve original error msg on compound errors --- src/ACMECert.php | 5 ++++- src/ACMEv2.php | 18 ++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/ACMECert.php b/src/ACMECert.php index 3bd787d..01d0de4 100644 --- a/src/ACMECert.php +++ b/src/ACMECert.php @@ -258,7 +258,10 @@ function($domain){ $this->log('Validation failed: '.$opts['domain']); $error=$body['challenges'][0]['error']; - throw new ACME_Exception($error['type'],'Challenge validation failed: '.$error['detail']); + throw $this->create_ACME_Exception( + $error['type'], + 'Challenge validation failed: '.$error['detail'] + ); }else{ $this->log('Validation successful: '.$opts['domain']); } diff --git a/src/ACMEv2.php b/src/ACMEv2.php index 163506f..54a9e5d 100644 --- a/src/ACMEv2.php +++ b/src/ACMEv2.php @@ -116,6 +116,11 @@ public function log($txt){ error_log($txt); } + protected function create_ACME_Exception($type,$detail,$subproblems=array()){ + $this->log('ACME_Exception: '.$detail.' ('.$type.')'); + return new ACME_Exception($type,$detail,$subproblems); + } + protected function get_openssl_error(){ $out=array(); $arr=error_get_last(); @@ -347,7 +352,7 @@ function($carry,$item)use(&$code){ } if ($code[0]!='2') { - throw new Exception('Invalid HTTP-Status-Code received: '.$code.': '.$url); + throw new Exception('Invalid HTTP-Status-Code received: '.$code.': '.print_r($body,true)); } $ret=array( @@ -362,15 +367,16 @@ function($carry,$item)use(&$code){ private function handleError($error){ if ($error['type']==='compound' && isset($error['subproblems'][0])) { $error['type']=$error['subproblems'][0]['type']; - $error['detail']=$error['subproblems'][0]['detail']; + $error['detail'].=': '.$error['subproblems'][0]['detail']; } - - throw new ACME_Exception($error['type'],$error['detail'], + throw $this->create_ACME_Exception($error['type'],$error['detail'], array_map(function($subproblem){ - return new ACME_Exception( + return $this->create_ACME_Exception( $subproblem['type'], (isset($subproblem['identifier']['value'])? - '"'.$subproblem['identifier']['value'].'": ':'').$subproblem['detail'] + '"'.$subproblem['identifier']['value'].'": ': + '' + ).$subproblem['detail'] ); },isset($error['subproblems'])?$error['subproblems']:array()) ); From 13498b42ce088f60383cdc4b42166325d9939b4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Tue, 5 Apr 2022 22:01:42 +0200 Subject: [PATCH 21/42] added: notBefore/notAfter support / getCertificateChain(s): changed authz_reuse boolean parameter to array backwards compatibility to ACMECert v3.1.2 or older is retained --- README.md | 34 +++++++++++++++++++------- src/ACMECert.php | 62 +++++++++++++++++++++++++++++++++++++----------- 2 files changed, 74 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 48006bd..df562b0 100644 --- a/README.md +++ b/README.md @@ -586,7 +586,7 @@ Get certificate-chain (certificate + the intermediate certificate(s)). *This is what Apache >= 2.4.8 needs for [`SSLCertificateFile`](https://httpd.apache.org/docs/current/mod/mod_ssl.html#sslcertificatefile), and what Nginx needs for [`ssl_certificate`](http://nginx.org/en/docs/http/ngx_http_ssl_module.html#ssl_certificate).* ```php -public string ACMECert::getCertificateChain ( mixed $pem, array $domain_config, callable $callback, bool $authz_reuse = TRUE ) +public string ACMECert::getCertificateChain ( mixed $pem, array $domain_config, callable $callback, array $settings = array() ) ``` ###### Parameters > **`pem`** @@ -601,7 +601,7 @@ public string ACMECert::getCertificateChain ( mixed $pem, array $domain_config, > **`domain_config`** > -> An Array defining the domains and the corresponding challenge types to get a certificate for (up to 100 domains per certificate). +> An Array defining the domains and the corresponding challenge types to get a certificate for. > > The first one is used as `Common Name` for the certificate. > @@ -634,8 +634,7 @@ public string ACMECert::getCertificateChain ( mixed $pem, array $domain_config, > > ^^^ > > ``` > -> ###### Parameters -> **`opts`** +> The `$opts` array passed to the callback function contains the following keys: > >> **`$opts['domain']`** >> @@ -656,11 +655,30 @@ public string ACMECert::getCertificateChain ( mixed $pem, array $domain_config, >> 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)) -> **`authz_reuse`** (default: `TRUE`) + +> **`settings`** (optional) > -> If `FALSE` the callback function is always called for each domain and does not get skipped due to possibly already valid authorizations (authz) that are reused. This is achieved by deactivating already valid authorizations before getting new ones. +> This array can have the following keys: +>> **`authz_reuse`** (boolean / default: `TRUE`) +>> +>> If `FALSE` the callback function is always called for each domain and does not get skipped due to possibly already valid authorizations (authz) that are reused. This is achieved by deactivating already valid authorizations before getting new ones. +>> +>> > Hint: Under normal circumstances this is only needed when testing the callback function, not in production! > -> > Hint: Under normal circumstances this is only needed when testing the callback function, not in production! +>> **`notBefore`** / **`notAfter`** (mixed) +>> +>> can be one of the following: +>> * a string containing a RFC 3339 formated date +>> * a timestamp (integer) +>> +>> Example: Certificate valid for 3 days: +>> ```php +>> array( 'notAfter' => time() + (60*60*24) * 3 ) +>> ``` +>> or +>> ```php +>> array( 'notAfter' => '1970-01-01T01:22:17+01:00' ) +>> ``` ###### Return Values > Returns a PEM encoded certificate chain. @@ -675,7 +693,7 @@ Get all (default and alternate) certificate-chains. This function takes the same arguments as the [getCertificateChain](#acmecertgetcertificatechain) function above, but it returns an array of certificate chains instead of a single chain. ```php -public string ACMECert::getCertificateChains ( mixed $pem, array $domain_config, callable $callback, bool $authz_reuse = TRUE ) +public string ACMECert::getCertificateChains ( mixed $pem, array $domain_config, callable $callback, array $settings = array() ) ``` ###### Return Values diff --git a/src/ACMECert.php b/src/ACMECert.php index 01d0de4..7187b71 100644 --- a/src/ACMECert.php +++ b/src/ACMECert.php @@ -154,7 +154,9 @@ public function revoke($pem){ $this->log('Certificate revoked'); } - public function getCertificateChain($pem,$domain_config,$callback,$authz_reuse=true){ + public function getCertificateChain($pem,$domain_config,$callback,$opts=array()){ + $opts=$this->parseOpts($opts); + $domain_config=array_change_key_case($domain_config,CASE_LOWER); $domains=array_keys($domain_config); $authz_deactivated=false; @@ -163,20 +165,13 @@ public function getCertificateChain($pem,$domain_config,$callback,$authz_reuse=t // === Order === $this->log('Creating Order'); - $ret=$this->request('newOrder',array( - 'identifiers'=>array_map( - function($domain){ - return array('type'=>'dns','value'=>$domain); - }, - $domains - ) - )); + $ret=$this->request('newOrder',$this->makeOrder($domains,$opts)); $order=$ret['body']; $order_location=$ret['headers']['location']; $this->log('Order created: '.$order_location); // === Authorization === - if ($order['status']==='ready' && $authz_reuse) { + if ($order['status']==='ready' && $opts['authz_reuse']) { $this->log('All authorizations already valid, skipping validation altogether'); }else{ $groups=array(); @@ -195,7 +190,7 @@ function($domain){ ).$authorization['identifier']['value']; if ($authorization['status']==='valid') { - if ($authz_reuse) { + if ($opts['authz_reuse']) { $this->log('Authorization of '.$domain.' already valid, skipping validation'); }else{ $this->log('Authorization of '.$domain.' already valid, deactivating authorization'); @@ -316,8 +311,8 @@ function($domain){ throw new Exception('Order failed'); } - public function getCertificateChains($pem,$domain_config,$callback,$authz_reuse=true){ - $default_chain=$this->getCertificateChain($pem,$domain_config,$callback,$authz_reuse); + public function getCertificateChains($pem,$domain_config,$callback,$opts=array()){ + $default_chain=$this->getCertificateChain($pem,$domain_config,$callback,$opts); $out=array(); $out[$this->getTopIssuerCN($default_chain)]=$default_chain; @@ -327,7 +322,7 @@ public function getCertificateChains($pem,$domain_config,$callback,$authz_reuse= $out[$this->getTopIssuerCN($chain)]=$chain; } - $this->log('Received '.count($out).' chains: '.implode(', ',array_keys($out))); + $this->log('Received '.count($out).' chain(s): '.implode(', ',array_keys($out))); return $out; } @@ -438,6 +433,45 @@ public function generateALPNCertificate($domain_key_pem,$domain,$token){ return $out; } + private function parseOpts($opts){ + // backwards compatibility to ACMECert v3.1.2 or older + if (!is_array($opts)) $opts=array('authz_reuse'=>(bool)$opts); + if (!isset($opts['authz_reuse'])) $opts['authz_reuse']=true; + + $diff=array_diff_key( + $opts, + array_flip(array('authz_reuse','notAfter','notBefore')) + ); + + if (!empty($diff)){ + throw new Exception('getCertificateChain(s): Invalid option "'.key($diff).'"'); + } + + return $opts; + } + + private function setRFC3339Date(&$out,$key,$opts){ + if (isset($opts[$key])){ + $out[$key]=is_string($opts[$key])? + $opts[$key]: + date(DATE_RFC3339,$opts[$key]); + } + } + + private function makeOrder($domains,$opts){ + $order=array( + 'identifiers'=>array_map( + function($domain){ + return array('type'=>'dns','value'=>$domain); + }, + $domains + ) + ); + $this->setRFC3339Date($order,'notAfter',$opts); + $this->setRFC3339Date($order,'notBefore',$opts); + return $order; + } + private function parse_challenges($authorization,$type,&$url){ foreach($authorization['challenges'] as $challenge){ if ($challenge['type']!=$type) continue; From 049afc6cdba203092eb52c89e1a8f1d136a291c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Thu, 7 Apr 2022 00:55:24 +0200 Subject: [PATCH 22/42] ignore errors when status=valid --- src/ACMEv2.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ACMEv2.php b/src/ACMEv2.php index 54a9e5d..a183fc5 100644 --- a/src/ACMEv2.php +++ b/src/ACMEv2.php @@ -339,7 +339,7 @@ function($carry,$item)use(&$code){ case 'application/json': if ($code[0]=='2'){ // on non 2xx response: fall through to problem+json case $body=$this->json_decode($body); - if (isset($body['error'])) { + if (isset($body['error']) && !(isset($body['status']) && $body['status']==='valid')) { $this->handleError($body['error']); } break; From 303c61f078fa151df4f3f44afabf9cfa9c748aa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Thu, 7 Apr 2022 01:10:39 +0200 Subject: [PATCH 23/42] README.md: use CA URL in constructor instead of boolean backwards compatibility is retained (booleans can still be used) --- README.md | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index df562b0..1cfa977 100644 --- a/README.md +++ b/README.md @@ -93,14 +93,14 @@ use skoerfgen\ACMECert\ACMECert; #### Choose Certificate Authority (CA) ##### [Let's Encrypt](https://letsencrypt.org/) -> Live CA - https://acme-v02.api.letsencrypt.org/directory +> Live CA ```php -$ac=new ACMECert(); +$ac=new ACMECert('https://acme-v02.api.letsencrypt.org/directory'); ``` -> Staging CA - https://acme-staging-v02.api.letsencrypt.org/directory +> Staging CA ```php -$ac=new ACMECert(false); +$ac=new ACMECert('https://acme-staging-v02.api.letsencrypt.org/directory'); ``` ##### [Buypass](https://buypass.com/) @@ -367,17 +367,12 @@ try { Creates a new ACMECert instance. ```php -public ACMECert::__construct ( mixed $ca_url = TRUE ) +public ACMECert::__construct ( string $ca_url = 'https://acme-v02.api.letsencrypt.org/directory' ) ``` ###### Parameters > **`ca_url`** > -> can be one of the following: -> -> * a boolean: -> when `TRUE` the Let's Encrypt - Live CA (https://acme-v02.api.letsencrypt.org/directory) is used. -> when `FALSE` the Let's Encrypt - Staging CA (https://acme-staging-v02.api.letsencrypt.org/directory) is used. -> * a string containing the URL to an ACME CA directory endpoint. +> A string containing the URL to an ACME CA directory endpoint. ###### Return Values > Returns a new ACMECert instance. From d994376038862d505aae3122577642fdc4c72566 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Thu, 7 Apr 2022 19:42:10 +0200 Subject: [PATCH 24/42] readme updated --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 1cfa977..beb6969 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ It is self contained and contains a set of functions allowing you to: - manage account: [register](#acmecertregister)/[update](#acmecertupdate)/[deactivate](#acmecertdeactivateaccount) and [account key roll-over](#acmecertkeychange) - [get](#acmecertgetcertificatechain)/[revoke](#acmecertrevoke) certificates (to renew a certificate just get a new one) - [parse certificates](#acmecertparsecertificate) / get the [remaining days](#acmecertgetremainingdays) a certificate is still valid +- and more.. > see [Function Reference](#function-reference) for a full list It abstacts away the complexity of the ACME protocol to get a certificate From de00262b9ce4e6871d21200c889ea9163a7b5d11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Thu, 7 Apr 2022 23:26:55 +0200 Subject: [PATCH 25/42] removed: special handling of compound errors --- src/ACMEv2.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/ACMEv2.php b/src/ACMEv2.php index a183fc5..e9eb9f3 100644 --- a/src/ACMEv2.php +++ b/src/ACMEv2.php @@ -365,10 +365,6 @@ function($carry,$item)use(&$code){ } private function handleError($error){ - if ($error['type']==='compound' && isset($error['subproblems'][0])) { - $error['type']=$error['subproblems'][0]['type']; - $error['detail'].=': '.$error['subproblems'][0]['detail']; - } throw $this->create_ACME_Exception($error['type'],$error['detail'], array_map(function($subproblem){ return $this->create_ACME_Exception( From aeeac7032047778e5ba337f48675e5691d8baf95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Sat, 9 Apr 2022 00:02:11 +0200 Subject: [PATCH 26/42] renaming / added some comments --- src/ACMECert.php | 18 +++++++++--------- src/ACMEv2.php | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/ACMECert.php b/src/ACMECert.php index 7187b71..e4d7c02 100644 --- a/src/ACMECert.php +++ b/src/ACMECert.php @@ -154,8 +154,8 @@ public function revoke($pem){ $this->log('Certificate revoked'); } - public function getCertificateChain($pem,$domain_config,$callback,$opts=array()){ - $opts=$this->parseOpts($opts); + public function getCertificateChain($pem,$domain_config,$callback,$settings=array()){ + $settings=$this->parseSettings($settings); $domain_config=array_change_key_case($domain_config,CASE_LOWER); $domains=array_keys($domain_config); @@ -165,13 +165,13 @@ public function getCertificateChain($pem,$domain_config,$callback,$opts=array()) // === Order === $this->log('Creating Order'); - $ret=$this->request('newOrder',$this->makeOrder($domains,$opts)); + $ret=$this->request('newOrder',$this->makeOrder($domains,$settings)); $order=$ret['body']; $order_location=$ret['headers']['location']; $this->log('Order created: '.$order_location); // === Authorization === - if ($order['status']==='ready' && $opts['authz_reuse']) { + if ($order['status']==='ready' && $settings['authz_reuse']) { $this->log('All authorizations already valid, skipping validation altogether'); }else{ $groups=array(); @@ -190,7 +190,7 @@ public function getCertificateChain($pem,$domain_config,$callback,$opts=array()) ).$authorization['identifier']['value']; if ($authorization['status']==='valid') { - if ($opts['authz_reuse']) { + if ($settings['authz_reuse']) { $this->log('Authorization of '.$domain.' already valid, skipping validation'); }else{ $this->log('Authorization of '.$domain.' already valid, deactivating authorization'); @@ -311,8 +311,8 @@ public function getCertificateChain($pem,$domain_config,$callback,$opts=array()) throw new Exception('Order failed'); } - public function getCertificateChains($pem,$domain_config,$callback,$opts=array()){ - $default_chain=$this->getCertificateChain($pem,$domain_config,$callback,$opts); + public function getCertificateChains($pem,$domain_config,$callback,$settings=array()){ + $default_chain=$this->getCertificateChain($pem,$domain_config,$callback,$settings); $out=array(); $out[$this->getTopIssuerCN($default_chain)]=$default_chain; @@ -433,8 +433,8 @@ public function generateALPNCertificate($domain_key_pem,$domain,$token){ return $out; } - private function parseOpts($opts){ - // backwards compatibility to ACMECert v3.1.2 or older + private function parseSettings($opts){ + // authz_reuse: backwards compatibility to ACMECert v3.1.2 or older if (!is_array($opts)) $opts=array('authz_reuse'=>(bool)$opts); if (!isset($opts['authz_reuse'])) $opts['authz_reuse']=true; diff --git a/src/ACMEv2.php b/src/ACMEv2.php index e9eb9f3..6306681 100644 --- a/src/ACMEv2.php +++ b/src/ACMEv2.php @@ -40,7 +40,7 @@ class ACMEv2 { // Communication with Let's Encrypt via ACME v2 protocol ),$ch=null,$bits,$sha_bits,$directory,$resources,$jwk_header,$kid_header,$account_key,$thumbprint,$nonce; public function __construct($live=true){ - if (is_bool($live)) { + if (is_bool($live)){ // backwards compatibility to ACMECert v3.1.2 or older $this->directory=$this->directories[$live?'live':'staging']; }else{ $this->directory=$live; From 1e625184d250f3b60ee622f1677b7ceabd5504d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Sat, 9 Apr 2022 00:03:47 +0200 Subject: [PATCH 27/42] max_tries raised from 8 to 10 (~ 3 minutes -> ~ 5 minutes) --- src/ACMECert.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ACMECert.php b/src/ACMECert.php index e4d7c02..723bf42 100644 --- a/src/ACMECert.php +++ b/src/ACMECert.php @@ -500,7 +500,7 @@ private function parse_challenges($authorization,$type,&$url){ } private function poll($initial,$type,&$ret){ - $max_tries=8; + $max_tries=10; // ~ 5 minutes for($i=0;$i<$max_tries;$i++){ $ret=$this->request($type); $ret=$ret['body']; From 32ba28c6bc6dbac23c04f93d47efd87e24fd4721 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Wed, 13 Apr 2022 23:13:40 +0200 Subject: [PATCH 28/42] http header: content-type: only use media-type --- src/ACMEv2.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ACMEv2.php b/src/ACMEv2.php index 6306681..d06a808 100644 --- a/src/ACMEv2.php +++ b/src/ACMEv2.php @@ -335,7 +335,8 @@ function($carry,$item)use(&$code){ if (!empty($headers['replay-nonce'])) $this->nonce=$headers['replay-nonce']; if (!empty($headers['content-type'])){ - switch($headers['content-type']){ + list($headers['content-type'])=explode(';',$headers['content-type'],2); + switch(trim($headers['content-type'])){ case 'application/json': if ($code[0]=='2'){ // on non 2xx response: fall through to problem+json case $body=$this->json_decode($body); From e681ec115f780fee21fecacc906360ab307a8075 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Thu, 14 Apr 2022 01:32:55 +0200 Subject: [PATCH 29/42] earlier content-type parsing / readded content-type check --- src/ACMECert.php | 3 +++ src/ACMEv2.php | 20 ++++++++++++-------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/ACMECert.php b/src/ACMECert.php index 723bf42..fa0d1c1 100644 --- a/src/ACMECert.php +++ b/src/ACMECert.php @@ -517,6 +517,9 @@ private function poll($initial,$type,&$ret){ private function request_certificate($ret,$alternate=false){ $this->log('Requesting '.($alternate?'alternate':'default').' certificate-chain'); $ret=$this->request($ret['certificate'],''); + if ($ret['headers']['content-type']!=='application/pem-certificate-chain'){ + throw new Exception('Unexpected content-type: '.$ret['headers']['content-type']); + } $isser_cn=$this->getTopIssuerCN($ret['body']); diff --git a/src/ACMEv2.php b/src/ACMEv2.php index d06a808..e800ebe 100644 --- a/src/ACMEv2.php +++ b/src/ACMEv2.php @@ -318,12 +318,17 @@ function($carry,$item)use(&$code){ }else{ list($k,$v)=$parts; $k=strtolower(trim($k)); - if ($k==='link'){ - if (preg_match('/<(.*)>\s*;\s*rel=\"(.*)\"/',$v,$matches)){ - $carry[$k][$matches[2]][]=trim($matches[1]); - } - }else{ - $carry[$k]=trim($v); + switch($k){ + case 'link': + if (preg_match('/<(.*)>\s*;\s*rel=\"(.*)\"/',$v,$matches)){ + $carry[$k][$matches[2]][]=trim($matches[1]); + } + break; + case 'content-type': + list($v)=explode(';',$v,2); + default: + $carry[$k]=trim($v); + break; } } return $carry; @@ -335,8 +340,7 @@ function($carry,$item)use(&$code){ if (!empty($headers['replay-nonce'])) $this->nonce=$headers['replay-nonce']; if (!empty($headers['content-type'])){ - list($headers['content-type'])=explode(';',$headers['content-type'],2); - switch(trim($headers['content-type'])){ + switch($headers['content-type']){ case 'application/json': if ($code[0]=='2'){ // on non 2xx response: fall through to problem+json case $body=$this->json_decode($body); From 16662f509468a48797c66cdecf4ef7682408384f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Thu, 14 Apr 2022 20:59:36 +0200 Subject: [PATCH 30/42] parse each received certificate / log each isser CN --- src/ACMECert.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/ACMECert.php b/src/ACMECert.php index fa0d1c1..08d2ab2 100644 --- a/src/ACMECert.php +++ b/src/ACMECert.php @@ -322,7 +322,6 @@ public function getCertificateChains($pem,$domain_config,$callback,$settings=arr $out[$this->getTopIssuerCN($chain)]=$chain; } - $this->log('Received '.count($out).' chain(s): '.implode(', ',array_keys($out))); return $out; } @@ -521,7 +520,11 @@ private function request_certificate($ret,$alternate=false){ throw new Exception('Unexpected content-type: '.$ret['headers']['content-type']); } - $isser_cn=$this->getTopIssuerCN($ret['body']); + $chain=array(); + foreach($this->splitChain($ret['body']) as $cert){ + $info=$this->parseCertificate($cert); + $chain[]='['.$info['issuer']['CN'].']'; + } if (!$alternate) { if (isset($ret['headers']['link']['alternate'])){ @@ -530,7 +533,8 @@ private function request_certificate($ret,$alternate=false){ $this->alternate_chains=array(); } } - $this->log('Certificate-chain retrieved: '.$isser_cn); + + $this->log(($alternate?'Alternate':'Default').' certificate-chain retrieved: '.implode(' -> ',array_reverse($chain,true))); return $ret['body']; } From 7debaf56fa8b373c13569e6cd0e961debb639170 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Fri, 15 Apr 2022 00:01:37 +0200 Subject: [PATCH 31/42] if challenge type is not available, show available types --- src/ACMECert.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/ACMECert.php b/src/ACMECert.php index 08d2ab2..b9ef730 100644 --- a/src/ACMECert.php +++ b/src/ACMECert.php @@ -495,7 +495,15 @@ private function parse_challenges($authorization,$type,&$url){ break; } } - throw new Exception('Challenge type: "'.$type.'" not available'); + throw new Exception( + 'Challenge type: "'.$type.'" not available, for this challenge use '. + implode(' or ',array_map( + function($a){ + return '"'.$a['type'].'"'; + }, + $authorization['challenges'] + )) + ); } private function poll($initial,$type,&$ret){ From 8b0868ac51bd82886acd897ef842c732ab7bc393 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Fri, 15 Apr 2022 00:04:20 +0200 Subject: [PATCH 32/42] handle urn:ietf:params:acme:error:rateLimited if Retry-After header is set --- src/ACMEv2.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/ACMEv2.php b/src/ACMEv2.php index e800ebe..869ed5f 100644 --- a/src/ACMEv2.php +++ b/src/ACMEv2.php @@ -38,6 +38,7 @@ class ACMEv2 { // Communication with Let's Encrypt via ACME v2 protocol 'live'=>'https://acme-v02.api.letsencrypt.org/directory', 'staging'=>'https://acme-staging-v02.api.letsencrypt.org/directory' ),$ch=null,$bits,$sha_bits,$directory,$resources,$jwk_header,$kid_header,$account_key,$thumbprint,$nonce; + private $retry_after=null; public function __construct($live=true){ if (is_bool($live)){ // backwards compatibility to ACMECert v3.1.2 or older @@ -181,6 +182,12 @@ protected function request($type,$payload='',$retry=false){ $this->log('Replay-Nonce expired, retrying previous request'); return $this->request($type,$payload,true); } + if (!$retry && $e->getType()==='urn:ietf:params:acme:error:rateLimited' && $this->retry_after!==null) { + if ($this->retry_after>300) throw $e; // only wait for max. 5 minutes + $this->log('Retrying in '.$this->retry_after.'s'); + sleep($this->retry_after); + return $this->request($type,$payload,true); + } throw $e; // rethrow all other exceptions } @@ -351,7 +358,15 @@ function($carry,$item)use(&$code){ } case 'application/problem+json': $body=$this->json_decode($body); + if (isset($headers['retry-after'])){ + if (is_numeric($headers['retry-after'])){ + $this->retry_after=ceil($headers['retry-after']); + }else{ + $this->retry_after=strtotime($headers['retry-after'])-time(); + } + } $this->handleError($body); + $this->retry_after=null; break; } } From a4795b993f4cf6d1861a5b6daf1e3718e75be7fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Fri, 15 Apr 2022 01:50:00 +0200 Subject: [PATCH 33/42] better Retry-After/rate limit handling --- src/ACMEv2.php | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/src/ACMEv2.php b/src/ACMEv2.php index 869ed5f..21559c3 100644 --- a/src/ACMEv2.php +++ b/src/ACMEv2.php @@ -38,7 +38,7 @@ class ACMEv2 { // Communication with Let's Encrypt via ACME v2 protocol 'live'=>'https://acme-v02.api.letsencrypt.org/directory', 'staging'=>'https://acme-staging-v02.api.letsencrypt.org/directory' ),$ch=null,$bits,$sha_bits,$directory,$resources,$jwk_header,$kid_header,$account_key,$thumbprint,$nonce; - private $retry_after=null; + private $delay_until=null; public function __construct($live=true){ if (is_bool($live)){ // backwards compatibility to ACMECert v3.1.2 or older @@ -173,6 +173,8 @@ protected function request($type,$payload='',$retry=false){ $type='_tmp'; } + $this->handleDelay(); + try { $ret=$this->http_request($this->resources[$type],json_encode( $this->jws_encapsulate($type,$payload) @@ -182,10 +184,8 @@ protected function request($type,$payload='',$retry=false){ $this->log('Replay-Nonce expired, retrying previous request'); return $this->request($type,$payload,true); } - if (!$retry && $e->getType()==='urn:ietf:params:acme:error:rateLimited' && $this->retry_after!==null) { - if ($this->retry_after>300) throw $e; // only wait for max. 5 minutes - $this->log('Retrying in '.$this->retry_after.'s'); - sleep($this->retry_after); + if (!$retry && $e->getType()==='urn:ietf:params:acme:error:rateLimited' && $this->delay_until!==null) { + $this->handleDelay(); return $this->request($type,$payload,true); } throw $e; // rethrow all other exceptions @@ -199,6 +199,15 @@ protected function request($type,$payload='',$retry=false){ return $ret; } + private function handleDelay(){ + if ($this->delay_until===null) return; + $delta=$this->delay_until-time(); + if ($delta<1) return; + $this->log('Rate Limit - Delaying '.$delta.'s'); + sleep($delta); + $this->delay_until=null; + } + protected function jws_encapsulate($type,$payload,$is_inner_jws=false){ // RFC7515 if ($type==='newAccount' || $is_inner_jws) { $protected=$this->jwk_header; @@ -346,6 +355,15 @@ function($carry,$item)use(&$code){ if (!empty($headers['replay-nonce'])) $this->nonce=$headers['replay-nonce']; + if (isset($headers['retry-after'])){ + if (is_numeric($headers['retry-after'])){ + $this->delay_until=time()+ceil($headers['retry-after']); + }else{ + $this->delay_until=strtotime($headers['retry-after']); + } + if ($this->delay_until-time()>300) $this->delay_until=null; // wait for max. 5 minutes + } + if (!empty($headers['content-type'])){ switch($headers['content-type']){ case 'application/json': @@ -358,15 +376,7 @@ function($carry,$item)use(&$code){ } case 'application/problem+json': $body=$this->json_decode($body); - if (isset($headers['retry-after'])){ - if (is_numeric($headers['retry-after'])){ - $this->retry_after=ceil($headers['retry-after']); - }else{ - $this->retry_after=strtotime($headers['retry-after'])-time(); - } - } $this->handleError($body); - $this->retry_after=null; break; } } From 036fa6c73f5aced6c9dcec13b492ff84545e3f0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Fri, 15 Apr 2022 02:56:50 +0200 Subject: [PATCH 34/42] README.md: External Account Binding --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index beb6969..19111da 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ with a few lines of PHP. It is self contained and contains a set of functions allowing you to: - generate [RSA](#acmecertgeneratersakey) / [EC (Elliptic Curve)](#acmecertgenerateeckey) keys -- manage account: [register](#acmecertregister)/[update](#acmecertupdate)/[deactivate](#acmecertdeactivateaccount) and [account key roll-over](#acmecertkeychange) +- manage account: [register](#acmecertregister)([EAB](#acmecertregistereab))/[update](#acmecertupdate)/[deactivate](#acmecertdeactivateaccount) and [account key roll-over](#acmecertkeychange) - [get](#acmecertgetcertificatechain)/[revoke](#acmecertrevoke) certificates (to renew a certificate just get a new one) - [parse certificates](#acmecertparsecertificate) / get the [remaining days](#acmecertgetremainingdays) a certificate is still valid - and more.. From b53631477809eb41b285da1fc3af31ba642b8d69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Fri, 15 Apr 2022 03:15:13 +0200 Subject: [PATCH 35/42] readded: chain count/info --- src/ACMECert.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ACMECert.php b/src/ACMECert.php index b9ef730..056df14 100644 --- a/src/ACMECert.php +++ b/src/ACMECert.php @@ -322,6 +322,7 @@ public function getCertificateChains($pem,$domain_config,$callback,$settings=arr $out[$this->getTopIssuerCN($chain)]=$chain; } + $this->log('Received '.count($out).' chains: '.implode(', ',array_keys($out))); return $out; } From 1f306d21a8e6ee307fe5a308fdc5aa6372343e15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Fri, 15 Apr 2022 21:01:01 +0200 Subject: [PATCH 36/42] improved retry-after handling --- src/ACMEv2.php | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/ACMEv2.php b/src/ACMEv2.php index 21559c3..21aa8b7 100644 --- a/src/ACMEv2.php +++ b/src/ACMEv2.php @@ -173,8 +173,6 @@ protected function request($type,$payload='',$retry=false){ $type='_tmp'; } - $this->handleDelay(); - try { $ret=$this->http_request($this->resources[$type],json_encode( $this->jws_encapsulate($type,$payload) @@ -185,7 +183,6 @@ protected function request($type,$payload='',$retry=false){ return $this->request($type,$payload,true); } if (!$retry && $e->getType()==='urn:ietf:params:acme:error:rateLimited' && $this->delay_until!==null) { - $this->handleDelay(); return $this->request($type,$payload,true); } throw $e; // rethrow all other exceptions @@ -199,15 +196,6 @@ protected function request($type,$payload='',$retry=false){ return $ret; } - private function handleDelay(){ - if ($this->delay_until===null) return; - $delta=$this->delay_until-time(); - if ($delta<1) return; - $this->log('Rate Limit - Delaying '.$delta.'s'); - sleep($delta); - $this->delay_until=null; - } - protected function jws_encapsulate($type,$payload,$is_inner_jws=false){ // RFC7515 if ($type==='newAccount' || $is_inner_jws) { $protected=$this->jwk_header; @@ -282,6 +270,16 @@ private function http_request($url,$data=null){ throw new Exception('Can not connect, no cURL or fopen wrappers enabled !'); } } + + if ($this->delay_until!==null){ + $delta=$this->delay_until-time(); + if ($delta>0){ + $this->log('Delaying '.$delta.'s (rate limit)'); + sleep($delta); + } + $this->delay_until=null; + } + $method=$data===false?'HEAD':($data===null?'GET':'POST'); $user_agent='ACMECert v3.2.0 (+https://github.com/skoerfgen/ACMECert)'; $header=($data===null||$data===false)?array():array('Content-Type: application/jose+json'); @@ -361,7 +359,9 @@ function($carry,$item)use(&$code){ }else{ $this->delay_until=strtotime($headers['retry-after']); } - if ($this->delay_until-time()>300) $this->delay_until=null; // wait for max. 5 minutes + $tmp=$this->delay_until-time(); + // ignore delay if not in range 1s..5min + if ($tmp>300 || $tmp<1) $this->delay_until=null; } if (!empty($headers['content-type'])){ From 304933664b929409730e62428b7dbc1ac18eea07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Fri, 15 Apr 2022 21:11:37 +0200 Subject: [PATCH 37/42] log: chains -> chain(s) --- src/ACMECert.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ACMECert.php b/src/ACMECert.php index 056df14..016c7cf 100644 --- a/src/ACMECert.php +++ b/src/ACMECert.php @@ -322,7 +322,7 @@ public function getCertificateChains($pem,$domain_config,$callback,$settings=arr $out[$this->getTopIssuerCN($chain)]=$chain; } - $this->log('Received '.count($out).' chains: '.implode(', ',array_keys($out))); + $this->log('Received '.count($out).' chain(s): '.implode(', ',array_keys($out))); return $out; } From 81546a6127a8d3847fb1e145172b3a879d248530 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Sat, 16 Apr 2022 00:33:36 +0200 Subject: [PATCH 38/42] bugfix: on order restart: keep last settings --- src/ACMECert.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ACMECert.php b/src/ACMECert.php index 016c7cf..209fe56 100644 --- a/src/ACMECert.php +++ b/src/ACMECert.php @@ -211,7 +211,8 @@ public function getCertificateChain($pem,$domain_config,$callback,$settings=arra if ($authz_deactivated){ $this->log('Restarting Order after deactivating already valid authorizations'); - return $this->getCertificateChain($pem,$domain_config,$callback); + $settings['authz_reuse']=true; + return $this->getCertificateChain($pem,$domain_config,$callback,$settings); } // make sure dns-01 comes last to avoid DNS problems for other challenges From 6523a2bad0f97baf489920d5a2e0f1e9888b65dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Sat, 16 Apr 2022 21:31:15 +0200 Subject: [PATCH 39/42] examples reordered --- README.md | 95 +++++++++++++++++++++++++++---------------------------- 1 file changed, 47 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 19111da..fb6177c 100644 --- a/README.md +++ b/README.md @@ -171,43 +171,6 @@ $ret=$ac->registerEAB(true,'INSERT_EAB_KEY_ID_HERE','INSERT_EAB_HMAC_HERE','info print_r($ret); ``` -#### Get Account Information -```php -$ac->loadAccountKey('file://'.'account_key.pem'); -$ret=$ac->getAccount(); -print_r($ret); -``` - -#### Account Key Roll-over -```php -$ac->loadAccountKey('file://'.'account_key.pem'); -$ret=$ac->keyChange('file://'.'new_account_key.pem'); -print_r($ret); -``` - -#### Deactivate Account -```php -$ac->loadAccountKey('file://'.'account_key.pem'); -$ret=$ac->deactivateAccount(); -print_r($ret); -``` - -#### Revoke Certificate -```php -$ac->loadAccountKey('file://'.'account_key.pem'); -$ac->revoke('file://'.'fullchain.pem'); -``` - -#### Get Remaining Days -```php -$days=$ac->getRemainingDays('file://'.'fullchain.pem'); // certificate or certificate-chain -if ($days>30) { // renew 30 days before expiry - die('Certificate still good, exiting..'); -} -// get new certificate here.. -``` -> This allows you to run your renewal script without the need to time it exactly, just run it often enough. (cronjob) - #### Get Certificate using `http-01` challenge ```php $ac->loadAccountKey('file://'.'account_key.pem'); @@ -230,17 +193,6 @@ $fullchain=$ac->getCertificateChain('file://'.'cert_private_key.pem',$domain_con file_put_contents('fullchain.pem',$fullchain); ``` -#### Get alternate chains -```php -$chains=$ac->getCertificateChains('file://'.'cert_private_key.pem',$domain_config,$handler); -if (isset($chains['ISRG Root X1'])){ // use alternate chain 'ISRG Root X1' - $fullchain=$chains['ISRG Root X1']; -}else{ // use default chain if 'ISRG Root X1' is not present - $fullchain=reset($chains); -} -file_put_contents('fullchain.pem',$fullchain); -``` - #### Get Certificate using all (`http-01`,`dns-01` and `tls-alpn-01`) challenge types together ```php $ac->loadAccountKey('file://'.'account_key.pem'); @@ -314,9 +266,56 @@ $handler=function($opts) use ($ac){ $fullchain=$ac->getCertificateChain('file://'.'cert_private_key.pem',$domain_config,$handler); file_put_contents('fullchain.pem',$fullchain); +``` + +#### Get alternate chains +```php +$chains=$ac->getCertificateChains('file://'.'cert_private_key.pem',$domain_config,$handler); +if (isset($chains['ISRG Root X1'])){ // use alternate chain 'ISRG Root X1' + $fullchain=$chains['ISRG Root X1']; +}else{ // use default chain if 'ISRG Root X1' is not present + $fullchain=reset($chains); +} +file_put_contents('fullchain.pem',$fullchain); +``` +#### Revoke Certificate +```php +$ac->loadAccountKey('file://'.'account_key.pem'); +$ac->revoke('file://'.'fullchain.pem'); +``` + +#### Get Account Information +```php +$ac->loadAccountKey('file://'.'account_key.pem'); +$ret=$ac->getAccount(); +print_r($ret); ``` +#### Account Key Roll-over +```php +$ac->loadAccountKey('file://'.'account_key.pem'); +$ret=$ac->keyChange('file://'.'new_account_key.pem'); +print_r($ret); +``` + +#### Deactivate Account +```php +$ac->loadAccountKey('file://'.'account_key.pem'); +$ret=$ac->deactivateAccount(); +print_r($ret); +``` + +#### Get Remaining Days +```php +$days=$ac->getRemainingDays('file://'.'fullchain.pem'); // certificate or certificate-chain +if ($days>30) { // renew 30 days before expiry + die('Certificate still good, exiting..'); +} +// get new certificate here.. +``` +> This allows you to run your renewal script without the need to time it exactly, just run it often enough. (cronjob) + ## Logging ACMECert logs its actions using `error_log`, which logs messages to stderr per default in PHP CLI so it is easy to log to a file instead: From 732517d2a98ff161143f2b0eb4846c9a430c6ed5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Sat, 16 Apr 2022 21:40:40 +0200 Subject: [PATCH 40/42] External Account Binding (EAB) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index fb6177c..d4e240c 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ with a few lines of PHP. It is self contained and contains a set of functions allowing you to: - generate [RSA](#acmecertgeneratersakey) / [EC (Elliptic Curve)](#acmecertgenerateeckey) keys -- manage account: [register](#acmecertregister)([EAB](#acmecertregistereab))/[update](#acmecertupdate)/[deactivate](#acmecertdeactivateaccount) and [account key roll-over](#acmecertkeychange) +- manage account: [register](#acmecertregister)/[External Account Binding (EAB)](#acmecertregistereab)/[update](#acmecertupdate)/[deactivate](#acmecertdeactivateaccount) and [account key roll-over](#acmecertkeychange) - [get](#acmecertgetcertificatechain)/[revoke](#acmecertrevoke) certificates (to renew a certificate just get a new one) - [parse certificates](#acmecertparsecertificate) / get the [remaining days](#acmecertgetremainingdays) a certificate is still valid - and more.. From 075a2d209006f9a7d0373a3dff6ac4e8c95621f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Wed, 20 Apr 2022 20:37:53 +0200 Subject: [PATCH 41/42] reset anti-replay nonce once used, so a new one is automatically requested if needed --- src/ACMEv2.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ACMEv2.php b/src/ACMEv2.php index 21aa8b7..03e3705 100644 --- a/src/ACMEv2.php +++ b/src/ACMEv2.php @@ -37,7 +37,7 @@ class ACMEv2 { // Communication with Let's Encrypt via ACME v2 protocol $directories=array( 'live'=>'https://acme-v02.api.letsencrypt.org/directory', 'staging'=>'https://acme-staging-v02.api.letsencrypt.org/directory' - ),$ch=null,$bits,$sha_bits,$directory,$resources,$jwk_header,$kid_header,$account_key,$thumbprint,$nonce; + ),$ch=null,$bits,$sha_bits,$directory,$resources,$jwk_header,$kid_header,$account_key,$thumbprint,$nonce=null; private $delay_until=null; public function __construct($live=true){ @@ -209,6 +209,7 @@ protected function jws_encapsulate($type,$payload,$is_inner_jws=false){ // RFC75 $ret=$this->http_request($this->resources['newNonce'],false); } $protected['nonce']=$this->nonce; + $this->nonce=null; } $protected['url']=$this->resources[$type]; From d59a7726a583aa399c8ac696f476bc15e92eb971 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6rfgen?= Date: Fri, 22 Apr 2022 22:38:56 +0200 Subject: [PATCH 42/42] check if ACME resource is available --- src/ACMEv2.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/ACMEv2.php b/src/ACMEv2.php index 03e3705..5d927e4 100644 --- a/src/ACMEv2.php +++ b/src/ACMEv2.php @@ -212,6 +212,10 @@ protected function jws_encapsulate($type,$payload,$is_inner_jws=false){ // RFC75 $this->nonce=null; } + if (!isset($this->resources[$type])){ + throw new Exception('Resource "'.$type.'" not available.'); + } + $protected['url']=$this->resources[$type]; $protected64=$this->base64url(json_encode($protected,JSON_UNESCAPED_SLASHES));