diff --git a/README.md b/README.md index f9dcbba..d4e240c 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 @@ -11,9 +11,10 @@ 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)/[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.. > see [Function Reference](#function-reference) for a full list It abstacts away the complexity of the ACME protocol to get a certificate @@ -49,7 +50,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 +77,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: @@ -77,77 +90,86 @@ require 'vendor/autoload.php'; use skoerfgen\ACMECert\ACMECert; ``` -## Usage Examples +## Usage / Examples -#### Choose Live or Staging Environment -> Live +#### Choose Certificate Authority (CA) +##### [Let's Encrypt](https://letsencrypt.org/) +> Live CA ```php -$ac=new ACMECert(); +$ac=new ACMECert('https://acme-v02.api.letsencrypt.org/directory'); ``` -> Staging + +> Staging CA ```php -$ac=new ACMECert(false); +$ac=new ACMECert('https://acme-staging-v02.api.letsencrypt.org/directory'); ``` -#### Generate RSA Private Key +##### [Buypass](https://buypass.com/) +> Live CA ```php -$key=$ac->generateRSAKey(2048); -file_put_contents('account_key.pem',$key); +$ac=new ACMECert('https://api.buypass.com/acme/directory'); ``` -> Equivalent to: `openssl genrsa -out account_key.pem 2048` -#### Generate EC Private Key +> Staging CA ```php -$key=$ac->generateECKey('P-384'); -file_put_contents('account_key.pem',$key); +$ac=new ACMECert('https://api.test4.buypass.no/acme/directory'); ``` -> Equivalent to: `openssl ecparam -name secp384r1 -genkey -noout -out account_key.pem` -#### Register Account Key with Let's Encrypt +##### [Google Trust Services](https://pki.goog/) +> Live CA ```php -$ac->loadAccountKey('file://'.'account_key.pem'); -$ret=$ac->register(true,'info@example.com'); -print_r($ret); +$ac=new ACMECert('https://dv.acme-v02.api.pki.goog/directory'); ``` -> **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.** +> Staging CA +```php +$ac=new ACMECert('https://dv.acme-v02.test-api.pki.goog/directory'); +``` -#### Get Account Information +##### [SSL.com](https://www.ssl.com/) +> Live CA ```php -$ac->loadAccountKey('file://'.'account_key.pem'); -$ret=$ac->getAccount(); -print_r($ret); +$ac=new ACMECert('https://acme.ssl.com/sslcom-dv-rsa'); ``` -#### Account Key Roll-over +##### [ZeroSSL](https://zerossl.com/) +> Live CA ```php -$ac->loadAccountKey('file://'.'account_key.pem'); -$ret=$ac->keyChange('file://'.'new_account_key.pem'); -print_r($ret); +$ac=new ACMECert('https://acme.zerossl.com/v2/DV90'); ``` -#### Deactivate Account +##### or any other ([ACME v2 - RFC 8555](https://tools.ietf.org/html/rfc8555)) compatible CA ```php -$ac->loadAccountKey('file://'.'account_key.pem'); -$ret=$ac->deactivateAccount(); -print_r($ret); +$ac=new ACMECert('INSERT_URL_TO_AMCE_CA_DIRECTORY_HERE'); ``` -#### Revoke Certificate +#### Generate RSA Private Key +```php +$key=$ac->generateRSAKey(2048); +file_put_contents('account_key.pem',$key); +``` +> Equivalent to: `openssl genrsa -out account_key.pem 2048` + +#### Generate EC Private Key +```php +$key=$ac->generateECKey('P-384'); +file_put_contents('account_key.pem',$key); +``` +> Equivalent to: `openssl ecparam -name secp384r1 -genkey -noout -out account_key.pem` + +#### Register Account Key with CA ```php $ac->loadAccountKey('file://'.'account_key.pem'); -$ac->revoke('file://'.'fullchain.pem'); +$ret=$ac->register(true,'info@example.com'); +print_r($ret); ``` -#### Get Remaining Days +#### Register Account Key with CA using External Account Binding ```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.. +$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); ``` -> 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 @@ -171,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'); @@ -255,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: @@ -309,12 +367,12 @@ try { Creates a new ACMECert instance. ```php -public ACMECert::__construct ( bool $live = TRUE ) +public ACMECert::__construct ( string $ca_url = 'https://acme-v02.api.letsencrypt.org/directory' ) ``` ###### Parameters -> **`live`** +> **`ca_url`** > -> 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 string containing the URL to an ACME CA directory endpoint. ###### Return Values > Returns a new ACMECert instance. @@ -381,16 +439,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.) > -> 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`** +> +> a string specifying the `EAB HMAC Key` > **`contacts`** > @@ -489,7 +581,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`** @@ -504,7 +596,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. > @@ -537,8 +629,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']`** >> @@ -559,11 +650,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. @@ -577,6 +687,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, array $settings = array() ) +``` ###### Return Values > Returns an array of PEM encoded certificate chains. @@ -716,6 +829,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 CAA Identities. + +--- + +### 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 `Exception` if an error occured getting the Subject Alternative Names. + +--- + +### 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 Terms Of Service. + +--- + > MIT License > > Copyright (c) 2018 Stefan Körfgen diff --git a/composer.json b/composer.json index b04452b..574125b 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "skoerfgen/acmecert", - "version": "3.1.2", - "description": "PHP client library for Let's Encrypt (ACME v2 - RFC 8555)", + "version": "3.2.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 0ae9364..209fe56 100644 --- a/src/ACMECert.php +++ b/src/ACMECert.php @@ -33,16 +33,44 @@ 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()){ + 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']; } @@ -75,6 +103,22 @@ 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 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); @@ -110,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,$settings=array()){ + $settings=$this->parseSettings($settings); + $domain_config=array_change_key_case($domain_config,CASE_LOWER); $domains=array_keys($domain_config); $authz_deactivated=false; @@ -119,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,$settings)); $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' && $settings['authz_reuse']) { $this->log('All authorizations already valid, skipping validation altogether'); }else{ $groups=array(); @@ -151,7 +190,7 @@ function($domain){ ).$authorization['identifier']['value']; if ($authorization['status']==='valid') { - if ($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'); @@ -172,7 +211,8 @@ function($domain){ 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 @@ -214,7 +254,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']); } @@ -269,8 +312,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,$settings=array()){ + $default_chain=$this->getCertificateChain($pem,$domain_config,$callback,$settings); $out=array(); $out[$this->getTopIssuerCN($default_chain)]=$default_chain; @@ -280,7 +323,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; } @@ -325,7 +368,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 )); } @@ -350,6 +393,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; @@ -376,6 +434,45 @@ public function generateALPNCertificate($domain_key_pem,$domain,$token){ return $out; } + 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=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; @@ -400,11 +497,19 @@ 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){ - $max_tries=8; + $max_tries=10; // ~ 5 minutes for($i=0;$i<$max_tries;$i++){ $ret=$this->request($type); $ret=$ret['body']; @@ -424,6 +529,13 @@ private function request_certificate($ret,$alternate=false){ if ($ret['headers']['content-type']!=='application/pem-certificate-chain'){ throw new Exception('Unexpected content-type: '.$ret['headers']['content-type']); } + + $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'])){ $this->alternate_chains=$ret['headers']['link']['alternate']; @@ -431,7 +543,8 @@ private function request_certificate($ret,$alternate=false){ $this->alternate_chains=array(); } } - $this->log('Certificate-chain retrieved'); + + $this->log(($alternate?'Alternate':'Default').' certificate-chain retrieved: '.implode(' -> ',array_reverse($chain,true))); return $ret['body']; } diff --git a/src/ACMEv2.php b/src/ACMEv2.php index 842d35e..5d927e4 100644 --- a/src/ACMEv2.php +++ b/src/ACMEv2.php @@ -37,10 +37,15 @@ 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=null; + private $delay_until=null; public function __construct($live=true){ - $this->directory=$this->directories[$this->mode=($live?'live':'staging')]; + if (is_bool($live)){ // backwards compatibility to ACMECert v3.1.2 or older + $this->directory=$this->directories[$live?'live':'staging']; + }else{ + $this->directory=$live; + } } public function __destruct(){ @@ -112,6 +117,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(); @@ -133,20 +143,30 @@ 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']) || + !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 + $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; @@ -162,6 +182,9 @@ 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->delay_until!==null) { + return $this->request($type,$payload,true); + } throw $e; // rethrow all other exceptions } @@ -186,12 +209,17 @@ 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; + } + + if (!isset($this->resources[$type])){ + throw new Exception('Resource "'.$type.'" not available.'); } $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, @@ -224,6 +252,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) { @@ -243,8 +275,18 @@ 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.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(); @@ -295,12 +337,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; @@ -311,27 +358,36 @@ 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']); + } + $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'])){ switch($headers['content-type']){ case 'application/json': - $body=$this->json_decode($body); - break; + if ($code[0]=='2'){ // on non 2xx response: fall through to problem+json case + $body=$this->json_decode($body); + if (isset($body['error']) && !(isset($body['status']) && $body['status']==='valid')) { + $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; } } 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( @@ -342,4 +398,19 @@ function($carry,$item)use(&$code){ return $ret; } + + private function handleError($error){ + throw $this->create_ACME_Exception($error['type'],$error['detail'], + array_map(function($subproblem){ + return $this->create_ACME_Exception( + $subproblem['type'], + (isset($subproblem['identifier']['value'])? + '"'.$subproblem['identifier']['value'].'": ': + '' + ).$subproblem['detail'] + ); + },isset($error['subproblems'])?$error['subproblems']:array()) + ); + } + }